universal-common-net-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.
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import gzip
|
|
6
|
+
import http.client
|
|
7
|
+
import http.cookiejar
|
|
8
|
+
import json
|
|
9
|
+
import socket
|
|
10
|
+
import ssl
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import zlib
|
|
14
|
+
from typing import Any, AsyncIterator, Callable, Iterable, Optional, Union
|
|
15
|
+
from urllib.parse import urlencode, urljoin, urlparse
|
|
16
|
+
|
|
17
|
+
class HttpMethod:
|
|
18
|
+
GET = "GET"
|
|
19
|
+
POST = "POST"
|
|
20
|
+
PUT = "PUT"
|
|
21
|
+
DELETE = "DELETE"
|
|
22
|
+
HEAD = "HEAD"
|
|
23
|
+
OPTIONS = "OPTIONS"
|
|
24
|
+
TRACE = "TRACE"
|
|
25
|
+
PATCH = "PATCH"
|
|
26
|
+
|
|
27
|
+
class HttpStatusCode:
|
|
28
|
+
CONTINUE = 100
|
|
29
|
+
SWITCHING_PROTOCOLS = 101
|
|
30
|
+
PROCESSING = 102
|
|
31
|
+
EARLY_HINTS = 103
|
|
32
|
+
OK = 200
|
|
33
|
+
CREATED = 201
|
|
34
|
+
ACCEPTED = 202
|
|
35
|
+
NON_AUTHORITATIVE_INFORMATION = 203
|
|
36
|
+
NO_CONTENT = 204
|
|
37
|
+
RESET_CONTENT = 205
|
|
38
|
+
PARTIAL_CONTENT = 206
|
|
39
|
+
MULTI_STATUS = 207
|
|
40
|
+
ALREADY_REPORTED = 208
|
|
41
|
+
IM_USED = 226
|
|
42
|
+
MULTIPLE_CHOICES = 300
|
|
43
|
+
AMBIGUOUS = 300
|
|
44
|
+
MOVED_PERMANENTLY = 301
|
|
45
|
+
MOVED = 301
|
|
46
|
+
FOUND = 302
|
|
47
|
+
REDIRECT = 302
|
|
48
|
+
SEE_OTHER = 303
|
|
49
|
+
REDIRECT_METHOD = 303
|
|
50
|
+
NOT_MODIFIED = 304
|
|
51
|
+
USE_PROXY = 305
|
|
52
|
+
UNUSED = 306
|
|
53
|
+
TEMPORARY_REDIRECT = 307
|
|
54
|
+
REDIRECT_KEEP_VERB = 307
|
|
55
|
+
PERMANENT_REDIRECT = 308
|
|
56
|
+
BAD_REQUEST = 400
|
|
57
|
+
UNAUTHORIZED = 401
|
|
58
|
+
PAYMENT_REQUIRED = 402
|
|
59
|
+
FORBIDDEN = 403
|
|
60
|
+
NOT_FOUND = 404
|
|
61
|
+
METHOD_NOT_ALLOWED = 405
|
|
62
|
+
NOT_ACCEPTABLE = 406
|
|
63
|
+
PROXY_AUTHENTICATION_REQUIRED = 407
|
|
64
|
+
REQUEST_TIMEOUT = 408
|
|
65
|
+
CONFLICT = 409
|
|
66
|
+
GONE = 410
|
|
67
|
+
LENGTH_REQUIRED = 411
|
|
68
|
+
PRECONDITION_FAILED = 412
|
|
69
|
+
REQUEST_ENTITY_TOO_LARGE = 413
|
|
70
|
+
REQUEST_URI_TOO_LONG = 414
|
|
71
|
+
UNSUPPORTED_MEDIA_TYPE = 415
|
|
72
|
+
REQUESTED_RANGE_NOT_SATISFIABLE = 416
|
|
73
|
+
EXPECTATION_FAILED = 417
|
|
74
|
+
IM_A_TEAPOT = 418
|
|
75
|
+
MISDIRECTED_REQUEST = 421
|
|
76
|
+
UNPROCESSABLE_ENTITY = 422
|
|
77
|
+
LOCKED = 423
|
|
78
|
+
FAILED_DEPENDENCY = 424
|
|
79
|
+
TOO_EARLY = 425
|
|
80
|
+
UPGRADE_REQUIRED = 426
|
|
81
|
+
PRECONDITION_REQUIRED = 428
|
|
82
|
+
TOO_MANY_REQUESTS = 429
|
|
83
|
+
REQUEST_HEADER_FIELDS_TOO_LARGE = 431
|
|
84
|
+
UNAVAILABLE_FOR_LEGAL_REASONS = 451
|
|
85
|
+
INTERNAL_SERVER_ERROR = 500
|
|
86
|
+
NOT_IMPLEMENTED = 501
|
|
87
|
+
BAD_GATEWAY = 502
|
|
88
|
+
SERVICE_UNAVAILABLE = 503
|
|
89
|
+
GATEWAY_TIMEOUT = 504
|
|
90
|
+
HTTP_VERSION_NOT_SUPPORTED = 505
|
|
91
|
+
VARIANT_ALSO_NEGOTIATES = 506
|
|
92
|
+
INSUFFICIENT_STORAGE = 507
|
|
93
|
+
LOOP_DETECTED = 508
|
|
94
|
+
NOT_EXTENDED = 510
|
|
95
|
+
NETWORK_AUTHENTICATION_REQUIRED = 511
|
|
96
|
+
|
|
97
|
+
class HttpVersion:
|
|
98
|
+
"""HTTP protocol versions recognized by HttpRequestMessage.version.
|
|
99
|
+
|
|
100
|
+
Only HTTP/1.0 and HTTP/1.1 are actually sendable: Python's standard library
|
|
101
|
+
(http.client) implements nothing above HTTP/1.1. HTTP/2 requires binary framing,
|
|
102
|
+
HPACK header compression, and multiplexed stream state (see the third-party 'h2'
|
|
103
|
+
package); HTTP/3 additionally requires QUIC over UDP (see 'aioquic'). The 2.0/3.0
|
|
104
|
+
constants exist so callers can refer to them symbolically; using them raises
|
|
105
|
+
NotImplementedError at send time rather than silently downgrading or hanging.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
HTTP_1_0 = "1.0"
|
|
109
|
+
HTTP_1_1 = "1.1"
|
|
110
|
+
HTTP_2_0 = "2.0"
|
|
111
|
+
HTTP_3_0 = "3.0"
|
|
112
|
+
|
|
113
|
+
_SUPPORTED_HTTP_VERSIONS = {
|
|
114
|
+
HttpVersion.HTTP_1_0: (10, "HTTP/1.0"),
|
|
115
|
+
HttpVersion.HTTP_1_1: (11, "HTTP/1.1"),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
class HttpCompletionOption:
|
|
119
|
+
"""Mirrors System.Net.Http.HttpCompletionOption."""
|
|
120
|
+
|
|
121
|
+
RESPONSE_CONTENT_READ = "response_content_read"
|
|
122
|
+
RESPONSE_HEADERS_READ = "response_headers_read"
|
|
123
|
+
|
|
124
|
+
class HttpHeaders:
|
|
125
|
+
"""Case-insensitive, multi-value header collection, matching the semantics of System.Net.Http.Headers.HttpHeaders."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, items: Optional[Iterable[tuple[str, str]]] = None):
|
|
128
|
+
self._entries: list[tuple[str, str]] = []
|
|
129
|
+
if items:
|
|
130
|
+
self.update(items)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _normalize(name: str) -> str:
|
|
134
|
+
return name.lower()
|
|
135
|
+
|
|
136
|
+
def add(self, name: str, value: str):
|
|
137
|
+
self._entries.append((name, value))
|
|
138
|
+
|
|
139
|
+
def get_all(self, name: str) -> list[str]:
|
|
140
|
+
key = self._normalize(name)
|
|
141
|
+
return [value for entry_name, value in self._entries if self._normalize(entry_name) == key]
|
|
142
|
+
|
|
143
|
+
def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
|
|
144
|
+
values = self.get_all(name)
|
|
145
|
+
return values[0] if values else default
|
|
146
|
+
|
|
147
|
+
def remove(self, name: str):
|
|
148
|
+
key = self._normalize(name)
|
|
149
|
+
self._entries = [entry for entry in self._entries if self._normalize(entry[0]) != key]
|
|
150
|
+
|
|
151
|
+
def pop(self, name: str, default: Optional[str] = None) -> Optional[str]:
|
|
152
|
+
values = self.get_all(name)
|
|
153
|
+
self.remove(name)
|
|
154
|
+
return values[0] if values else default
|
|
155
|
+
|
|
156
|
+
def update(self, other):
|
|
157
|
+
if other is None:
|
|
158
|
+
return
|
|
159
|
+
pairs = other.items() if hasattr(other, "items") else other
|
|
160
|
+
for name, value in pairs:
|
|
161
|
+
self.add(name, value)
|
|
162
|
+
|
|
163
|
+
def items(self) -> list[tuple[str, str]]:
|
|
164
|
+
return list(self._entries)
|
|
165
|
+
|
|
166
|
+
def keys(self) -> list[str]:
|
|
167
|
+
return list(self)
|
|
168
|
+
|
|
169
|
+
def __setitem__(self, name: str, value: str):
|
|
170
|
+
self.remove(name)
|
|
171
|
+
self.add(name, value)
|
|
172
|
+
|
|
173
|
+
def __getitem__(self, name: str) -> str:
|
|
174
|
+
values = self.get_all(name)
|
|
175
|
+
if not values:
|
|
176
|
+
raise KeyError(name)
|
|
177
|
+
return values[0]
|
|
178
|
+
|
|
179
|
+
def __delitem__(self, name: str):
|
|
180
|
+
self.remove(name)
|
|
181
|
+
|
|
182
|
+
def __contains__(self, name: str) -> bool:
|
|
183
|
+
return bool(self.get_all(name))
|
|
184
|
+
|
|
185
|
+
def __iter__(self):
|
|
186
|
+
seen = []
|
|
187
|
+
for name, _ in self._entries:
|
|
188
|
+
key = self._normalize(name)
|
|
189
|
+
if key not in seen:
|
|
190
|
+
seen.append(key)
|
|
191
|
+
yield name
|
|
192
|
+
|
|
193
|
+
def __len__(self) -> int:
|
|
194
|
+
return len(list(iter(self)))
|
|
195
|
+
|
|
196
|
+
def __repr__(self) -> str:
|
|
197
|
+
return f"HttpHeaders({self._entries!r})"
|
|
198
|
+
|
|
199
|
+
def _decompress_content(data: bytes, content_encoding: Optional[str]) -> bytes:
|
|
200
|
+
if not content_encoding:
|
|
201
|
+
return data
|
|
202
|
+
|
|
203
|
+
encoding = content_encoding.lower()
|
|
204
|
+
if encoding == "gzip":
|
|
205
|
+
return gzip.decompress(data)
|
|
206
|
+
if encoding == "deflate":
|
|
207
|
+
try:
|
|
208
|
+
return zlib.decompress(data)
|
|
209
|
+
except zlib.error:
|
|
210
|
+
return zlib.decompress(data, -zlib.MAX_WBITS)
|
|
211
|
+
return data
|
|
212
|
+
|
|
213
|
+
def _read_bounded(response: http.client.HTTPResponse, max_size: Optional[int]) -> bytes:
|
|
214
|
+
if max_size is None:
|
|
215
|
+
return response.read()
|
|
216
|
+
|
|
217
|
+
chunks = []
|
|
218
|
+
total = 0
|
|
219
|
+
while True:
|
|
220
|
+
chunk = response.read(65536)
|
|
221
|
+
if not chunk:
|
|
222
|
+
break
|
|
223
|
+
total += len(chunk)
|
|
224
|
+
if total > max_size:
|
|
225
|
+
raise ValueError(f"Response content exceeded the configured maximum buffer size of {max_size} bytes.")
|
|
226
|
+
chunks.append(chunk)
|
|
227
|
+
return b"".join(chunks)
|
|
228
|
+
|
|
229
|
+
class _StreamDecompressor:
|
|
230
|
+
"""Incremental counterpart to _decompress_content, for streamed response bodies."""
|
|
231
|
+
|
|
232
|
+
def __init__(self, content_encoding: Optional[str]):
|
|
233
|
+
encoding = (content_encoding or "").lower()
|
|
234
|
+
if encoding == "gzip":
|
|
235
|
+
self._decompressor = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
|
236
|
+
elif encoding == "deflate":
|
|
237
|
+
self._decompressor = zlib.decompressobj()
|
|
238
|
+
else:
|
|
239
|
+
self._decompressor = None
|
|
240
|
+
|
|
241
|
+
def decompress(self, chunk: bytes) -> bytes:
|
|
242
|
+
return self._decompressor.decompress(chunk) if self._decompressor else chunk
|
|
243
|
+
|
|
244
|
+
def flush(self) -> bytes:
|
|
245
|
+
return self._decompressor.flush() if self._decompressor else b""
|
|
246
|
+
|
|
247
|
+
class _ConnectionPool:
|
|
248
|
+
"""Thread-safe pool of idle keep-alive connections, keyed by destination.
|
|
249
|
+
|
|
250
|
+
Also tracks connections currently in flight so HttpClientHandler.cancel_pending_requests()
|
|
251
|
+
can forcibly abort them.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
def __init__(self):
|
|
255
|
+
self._lock = threading.Lock()
|
|
256
|
+
self._idle: dict[tuple, list] = {} # key -> list of (connection, idle_since)
|
|
257
|
+
self._in_flight: set = set()
|
|
258
|
+
|
|
259
|
+
def acquire(self, key: tuple, idle_timeout: Optional[float] = None, lifetime: Optional[float] = None):
|
|
260
|
+
while True:
|
|
261
|
+
with self._lock:
|
|
262
|
+
idle = self._idle.get(key)
|
|
263
|
+
if not idle:
|
|
264
|
+
return None
|
|
265
|
+
connection, idle_since = idle.pop()
|
|
266
|
+
|
|
267
|
+
now = time.monotonic()
|
|
268
|
+
created_at = getattr(connection, "_created_at", now)
|
|
269
|
+
expired = (idle_timeout is not None and now - idle_since > idle_timeout) or (
|
|
270
|
+
lifetime is not None and now - created_at > lifetime
|
|
271
|
+
)
|
|
272
|
+
if expired:
|
|
273
|
+
try:
|
|
274
|
+
connection.close()
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
with self._lock:
|
|
280
|
+
self._in_flight.add(connection)
|
|
281
|
+
return connection
|
|
282
|
+
|
|
283
|
+
def track(self, connection):
|
|
284
|
+
with self._lock:
|
|
285
|
+
self._in_flight.add(connection)
|
|
286
|
+
|
|
287
|
+
def untrack(self, connection):
|
|
288
|
+
with self._lock:
|
|
289
|
+
self._in_flight.discard(connection)
|
|
290
|
+
|
|
291
|
+
def discard(self, connection):
|
|
292
|
+
self.untrack(connection)
|
|
293
|
+
try:
|
|
294
|
+
connection.close()
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
|
|
298
|
+
def release(self, key: tuple, connection):
|
|
299
|
+
self.untrack(connection)
|
|
300
|
+
with self._lock:
|
|
301
|
+
self._idle.setdefault(key, []).append((connection, time.monotonic()))
|
|
302
|
+
|
|
303
|
+
def close_all(self):
|
|
304
|
+
with self._lock:
|
|
305
|
+
all_connections = [c for entries in self._idle.values() for c, _ in entries] + list(self._in_flight)
|
|
306
|
+
self._idle.clear()
|
|
307
|
+
self._in_flight.clear()
|
|
308
|
+
for connection in all_connections:
|
|
309
|
+
try:
|
|
310
|
+
connection.close()
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
def cancel_in_flight(self):
|
|
315
|
+
with self._lock:
|
|
316
|
+
in_flight = list(self._in_flight)
|
|
317
|
+
for connection in in_flight:
|
|
318
|
+
sock = getattr(connection, "sock", None)
|
|
319
|
+
if sock is not None:
|
|
320
|
+
try:
|
|
321
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
322
|
+
except OSError:
|
|
323
|
+
pass
|
|
324
|
+
try:
|
|
325
|
+
connection.close()
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
class HttpMessageHandler:
|
|
330
|
+
async def send_async(
|
|
331
|
+
self,
|
|
332
|
+
request: HttpRequestMessage,
|
|
333
|
+
timeout: Optional[float] = None,
|
|
334
|
+
completion_option: str = HttpCompletionOption.RESPONSE_CONTENT_READ,
|
|
335
|
+
) -> HttpResponseMessage:
|
|
336
|
+
raise NotImplementedError("Derived classes must implement send_async")
|
|
337
|
+
|
|
338
|
+
def close(self):
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
def cancel_pending_requests(self):
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
class DelegatingHandler(HttpMessageHandler):
|
|
345
|
+
"""Wraps another HttpMessageHandler to add cross-cutting behavior (logging, retries,
|
|
346
|
+
metrics, ...) around it, mirroring System.Net.Http.DelegatingHandler. Subclass and
|
|
347
|
+
override send_async, calling self.inner_handler.send_async(...) to continue the chain.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
def __init__(self, inner_handler: HttpMessageHandler):
|
|
351
|
+
self.inner_handler = inner_handler
|
|
352
|
+
|
|
353
|
+
async def send_async(
|
|
354
|
+
self,
|
|
355
|
+
request: HttpRequestMessage,
|
|
356
|
+
timeout: Optional[float] = None,
|
|
357
|
+
completion_option: str = HttpCompletionOption.RESPONSE_CONTENT_READ,
|
|
358
|
+
) -> HttpResponseMessage:
|
|
359
|
+
return await self.inner_handler.send_async(request, timeout=timeout, completion_option=completion_option)
|
|
360
|
+
|
|
361
|
+
def close(self):
|
|
362
|
+
self.inner_handler.close()
|
|
363
|
+
|
|
364
|
+
def cancel_pending_requests(self):
|
|
365
|
+
self.inner_handler.cancel_pending_requests()
|
|
366
|
+
|
|
367
|
+
class _CookieJarRequestAdapter:
|
|
368
|
+
"""Minimal adapter so http.cookiejar.CookieJar can inspect/mutate an HttpRequestMessage.
|
|
369
|
+
|
|
370
|
+
Implements the duck-typed "request" interface CookieJar.add_cookie_header/extract_cookies
|
|
371
|
+
expect (the same interface urllib.request.Request satisfies natively).
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
def __init__(self, request: HttpRequestMessage):
|
|
375
|
+
self._request = request
|
|
376
|
+
target = urlparse(request.request_uri)
|
|
377
|
+
self.host = target.hostname
|
|
378
|
+
self.type = target.scheme
|
|
379
|
+
self.origin_req_host = target.hostname
|
|
380
|
+
self.unverifiable = False
|
|
381
|
+
|
|
382
|
+
def get_full_url(self):
|
|
383
|
+
return self._request.request_uri
|
|
384
|
+
|
|
385
|
+
def has_header(self, name: str) -> bool:
|
|
386
|
+
return name in self._request.headers
|
|
387
|
+
|
|
388
|
+
def get_header(self, name: str, default=None):
|
|
389
|
+
return self._request.headers.get(name, default)
|
|
390
|
+
|
|
391
|
+
def header_items(self):
|
|
392
|
+
return self._request.headers.items()
|
|
393
|
+
|
|
394
|
+
def add_unredirected_header(self, name: str, value: str):
|
|
395
|
+
self._request.headers[name] = value
|
|
396
|
+
|
|
397
|
+
class _CookieJarResponseAdapter:
|
|
398
|
+
"""Minimal adapter so http.cookiejar.CookieJar can read Set-Cookie headers from an HttpHeaders collection."""
|
|
399
|
+
|
|
400
|
+
def __init__(self, headers: HttpHeaders):
|
|
401
|
+
self._headers = headers
|
|
402
|
+
|
|
403
|
+
def info(self):
|
|
404
|
+
return self
|
|
405
|
+
|
|
406
|
+
def get_all(self, name: str, default=None):
|
|
407
|
+
values = self._headers.get_all(name)
|
|
408
|
+
return values if values else (default if default is not None else [])
|
|
409
|
+
|
|
410
|
+
_REDIRECT_STATUS_CODES = {
|
|
411
|
+
HttpStatusCode.MOVED_PERMANENTLY,
|
|
412
|
+
HttpStatusCode.FOUND,
|
|
413
|
+
HttpStatusCode.SEE_OTHER,
|
|
414
|
+
HttpStatusCode.TEMPORARY_REDIRECT,
|
|
415
|
+
HttpStatusCode.PERMANENT_REDIRECT,
|
|
416
|
+
}
|
|
417
|
+
_METHOD_DOWNGRADE_STATUS_CODES = {HttpStatusCode.MOVED_PERMANENTLY, HttpStatusCode.FOUND, HttpStatusCode.SEE_OTHER}
|
|
418
|
+
_BODY_PRESERVING_REDIRECT_STATUS_CODES = {HttpStatusCode.TEMPORARY_REDIRECT, HttpStatusCode.PERMANENT_REDIRECT}
|
|
419
|
+
# Cookie is always recomputed fresh per hop by the cookie jar rather than carried forward.
|
|
420
|
+
_ALWAYS_STRIP_REDIRECT_HEADERS = {"cookie"}
|
|
421
|
+
_CROSS_HOST_STRIP_REDIRECT_HEADERS = {"authorization", "proxy-authorization"}
|
|
422
|
+
|
|
423
|
+
class HttpClientHandler(HttpMessageHandler):
|
|
424
|
+
def __init__(self):
|
|
425
|
+
# Each entry is (certfile, keyfile) or (certfile, keyfile, password).
|
|
426
|
+
self.client_certificates: list[tuple[str, ...]] = []
|
|
427
|
+
# (username, password); sent preemptively as HTTP Basic auth.
|
|
428
|
+
self.credentials: Optional[tuple[str, str]] = None
|
|
429
|
+
self.use_proxy = False
|
|
430
|
+
self.proxy: Optional[str] = None
|
|
431
|
+
self.allow_auto_redirect = True
|
|
432
|
+
self.max_automatic_redirects = 50
|
|
433
|
+
self.use_cookies = True
|
|
434
|
+
self.cookies = http.cookiejar.CookieJar()
|
|
435
|
+
# Called with (der_encoded_cert, ssl.SSLSocket) -> bool. When set, built-in certificate
|
|
436
|
+
# verification (including hostname checking) is skipped entirely and this callback is
|
|
437
|
+
# the sole authority on whether to keep the connection - e.g. certificate pinning, or
|
|
438
|
+
# accepting a self-signed cert in a dev/test environment. Mirrors the responsibility
|
|
439
|
+
# shift of .NET's ServerCertificateCustomValidationCallback.
|
|
440
|
+
self.server_certificate_custom_validation: Optional[Callable[[bytes, ssl.SSLSocket], bool]] = None
|
|
441
|
+
# None means unbounded; matches the practical effect of .NET's ~2GB default.
|
|
442
|
+
self.max_response_content_buffer_size: Optional[int] = None
|
|
443
|
+
self.pooled_connection_idle_timeout: Optional[float] = 90.0
|
|
444
|
+
self.pooled_connection_lifetime: Optional[float] = None
|
|
445
|
+
self.max_connections_per_server: Optional[int] = None
|
|
446
|
+
self._pool = _ConnectionPool()
|
|
447
|
+
self._semaphores: dict[tuple, asyncio.Semaphore] = {}
|
|
448
|
+
|
|
449
|
+
def _build_ssl_context(self) -> ssl.SSLContext:
|
|
450
|
+
context = ssl.create_default_context()
|
|
451
|
+
if self.server_certificate_custom_validation is not None:
|
|
452
|
+
context.check_hostname = False
|
|
453
|
+
context.verify_mode = ssl.CERT_NONE
|
|
454
|
+
for certificate in self.client_certificates:
|
|
455
|
+
certfile, keyfile = certificate[0], certificate[1]
|
|
456
|
+
password = certificate[2] if len(certificate) > 2 else None
|
|
457
|
+
context.load_cert_chain(certfile, keyfile, password)
|
|
458
|
+
return context
|
|
459
|
+
|
|
460
|
+
def _apply_credentials(self, request: HttpRequestMessage):
|
|
461
|
+
if self.credentials and "Authorization" not in request.headers:
|
|
462
|
+
username, password = self.credentials
|
|
463
|
+
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
|
|
464
|
+
request.headers["Authorization"] = f"Basic {token}"
|
|
465
|
+
|
|
466
|
+
def _resolve_proxy(self) -> Optional[tuple[str, int]]:
|
|
467
|
+
if not (self.use_proxy and self.proxy):
|
|
468
|
+
return None
|
|
469
|
+
proxy_url = self.proxy if "://" in self.proxy else f"http://{self.proxy}"
|
|
470
|
+
proxy = urlparse(proxy_url)
|
|
471
|
+
return proxy.hostname, proxy.port or (443 if proxy.scheme == "https" else 80)
|
|
472
|
+
|
|
473
|
+
def _connection_key_and_path(self, target, use_ssl: bool, path: str) -> tuple[tuple, str]:
|
|
474
|
+
proxy = self._resolve_proxy()
|
|
475
|
+
if proxy:
|
|
476
|
+
proxy_host, proxy_port = proxy
|
|
477
|
+
if use_ssl:
|
|
478
|
+
return ("proxy-tunnel", proxy_host, proxy_port, target.hostname, target.port or 443), path
|
|
479
|
+
return ("proxy-plain", proxy_host, proxy_port), target.geturl()
|
|
480
|
+
return ("direct", target.scheme, target.hostname, target.port or (443 if use_ssl else 80)), path
|
|
481
|
+
|
|
482
|
+
def _get_semaphore(self, pool_key: tuple) -> Optional[asyncio.Semaphore]:
|
|
483
|
+
if self.max_connections_per_server is None:
|
|
484
|
+
return None
|
|
485
|
+
semaphore = self._semaphores.get(pool_key)
|
|
486
|
+
if semaphore is None:
|
|
487
|
+
semaphore = asyncio.Semaphore(self.max_connections_per_server)
|
|
488
|
+
self._semaphores[pool_key] = semaphore
|
|
489
|
+
return semaphore
|
|
490
|
+
|
|
491
|
+
def _create_connection(self, target, use_ssl: bool, timeout: Optional[float]):
|
|
492
|
+
proxy = self._resolve_proxy()
|
|
493
|
+
if proxy:
|
|
494
|
+
proxy_host, proxy_port = proxy
|
|
495
|
+
if use_ssl:
|
|
496
|
+
conn = http.client.HTTPSConnection(proxy_host, proxy_port, timeout=timeout, context=self._build_ssl_context())
|
|
497
|
+
conn.set_tunnel(target.hostname, target.port or 443)
|
|
498
|
+
else:
|
|
499
|
+
conn = http.client.HTTPConnection(proxy_host, proxy_port, timeout=timeout)
|
|
500
|
+
elif use_ssl:
|
|
501
|
+
conn = http.client.HTTPSConnection(target.hostname, target.port or 443, timeout=timeout, context=self._build_ssl_context())
|
|
502
|
+
else:
|
|
503
|
+
conn = http.client.HTTPConnection(target.hostname, target.port or 80, timeout=timeout)
|
|
504
|
+
|
|
505
|
+
if use_ssl and self.server_certificate_custom_validation is not None:
|
|
506
|
+
conn.connect()
|
|
507
|
+
der_cert = conn.sock.getpeercert(binary_form=True)
|
|
508
|
+
if not self.server_certificate_custom_validation(der_cert, conn.sock):
|
|
509
|
+
conn.close()
|
|
510
|
+
raise ssl.SSLCertVerificationError("Server certificate was rejected by server_certificate_custom_validation.")
|
|
511
|
+
|
|
512
|
+
conn._created_at = time.monotonic()
|
|
513
|
+
return conn
|
|
514
|
+
|
|
515
|
+
def _build_streaming_response(self, conn, response: http.client.HTTPResponse, pool_key: tuple) -> HttpResponseMessage:
|
|
516
|
+
message = HttpResponseMessage(response.status)
|
|
517
|
+
message.headers.update(response.headers)
|
|
518
|
+
message.reason_phrase = response.reason
|
|
519
|
+
message.version = f"{response.version // 10}.{response.version % 10}"
|
|
520
|
+
|
|
521
|
+
content_encoding = response.getheader("Content-Encoding")
|
|
522
|
+
if content_encoding:
|
|
523
|
+
message.headers.remove("Content-Encoding")
|
|
524
|
+
message.headers.remove("Content-Length")
|
|
525
|
+
|
|
526
|
+
def on_complete(aborted: bool):
|
|
527
|
+
if aborted or response.will_close:
|
|
528
|
+
self._pool.discard(conn)
|
|
529
|
+
else:
|
|
530
|
+
self._pool.release(pool_key, conn)
|
|
531
|
+
|
|
532
|
+
message.content = _ResponseStreamContent(response, content_encoding, on_complete)
|
|
533
|
+
return message
|
|
534
|
+
|
|
535
|
+
def _send_sync(
|
|
536
|
+
self,
|
|
537
|
+
target,
|
|
538
|
+
use_ssl: bool,
|
|
539
|
+
method: str,
|
|
540
|
+
path: str,
|
|
541
|
+
headers: HttpHeaders,
|
|
542
|
+
body,
|
|
543
|
+
chunked: bool,
|
|
544
|
+
version: str,
|
|
545
|
+
timeout: Optional[float],
|
|
546
|
+
completion_option: str,
|
|
547
|
+
is_streamed_upload: bool,
|
|
548
|
+
) -> HttpResponseMessage:
|
|
549
|
+
pool_key, request_path = self._connection_key_and_path(target, use_ssl, path)
|
|
550
|
+
|
|
551
|
+
# A streamed (non-rewindable) upload body can't be safely retried on a stale
|
|
552
|
+
# pooled connection, so it always gets a fresh one.
|
|
553
|
+
conn = None if is_streamed_upload else self._pool.acquire(
|
|
554
|
+
pool_key, idle_timeout=self.pooled_connection_idle_timeout, lifetime=self.pooled_connection_lifetime
|
|
555
|
+
)
|
|
556
|
+
reused = conn is not None
|
|
557
|
+
if reused and getattr(conn, "sock", None) is not None:
|
|
558
|
+
conn.sock.settimeout(timeout)
|
|
559
|
+
if conn is None:
|
|
560
|
+
conn = self._create_connection(target, use_ssl, timeout)
|
|
561
|
+
self._pool.track(conn)
|
|
562
|
+
|
|
563
|
+
http_vsn, http_vsn_str = _SUPPORTED_HTTP_VERSIONS[version]
|
|
564
|
+
|
|
565
|
+
def do_request(connection):
|
|
566
|
+
connection._http_vsn, connection._http_vsn_str = http_vsn, http_vsn_str
|
|
567
|
+
connection.request(method, request_path, body=body, headers=headers, encode_chunked=chunked)
|
|
568
|
+
return connection.getresponse()
|
|
569
|
+
|
|
570
|
+
try:
|
|
571
|
+
try:
|
|
572
|
+
response = do_request(conn)
|
|
573
|
+
except (http.client.RemoteDisconnected, ConnectionError, OSError):
|
|
574
|
+
if not reused:
|
|
575
|
+
raise
|
|
576
|
+
self._pool.discard(conn)
|
|
577
|
+
conn = self._create_connection(target, use_ssl, timeout)
|
|
578
|
+
self._pool.track(conn)
|
|
579
|
+
response = do_request(conn)
|
|
580
|
+
|
|
581
|
+
if completion_option == HttpCompletionOption.RESPONSE_HEADERS_READ:
|
|
582
|
+
return self._build_streaming_response(conn, response, pool_key)
|
|
583
|
+
|
|
584
|
+
message = HttpResponseMessage.from_http_response(response, max_size=self.max_response_content_buffer_size)
|
|
585
|
+
if response.will_close:
|
|
586
|
+
self._pool.discard(conn)
|
|
587
|
+
else:
|
|
588
|
+
self._pool.release(pool_key, conn)
|
|
589
|
+
return message
|
|
590
|
+
except Exception:
|
|
591
|
+
self._pool.discard(conn)
|
|
592
|
+
raise
|
|
593
|
+
|
|
594
|
+
async def _send_once_async(
|
|
595
|
+
self,
|
|
596
|
+
request: HttpRequestMessage,
|
|
597
|
+
timeout: Optional[float],
|
|
598
|
+
completion_option: str,
|
|
599
|
+
) -> HttpResponseMessage:
|
|
600
|
+
version = request.version or HttpVersion.HTTP_1_1
|
|
601
|
+
if version not in _SUPPORTED_HTTP_VERSIONS:
|
|
602
|
+
raise NotImplementedError(
|
|
603
|
+
f"HTTP/{version} is not supported. Python's standard library (http.client) only "
|
|
604
|
+
"implements HTTP/1.0 and HTTP/1.1. HTTP/2 requires binary framing, HPACK header compression, "
|
|
605
|
+
"and multiplexed stream state (see the third-party 'h2' package); HTTP/3 additionally requires "
|
|
606
|
+
"QUIC over UDP (see 'aioquic'). Neither is implementable with the standard library alone."
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
target = urlparse(request.request_uri)
|
|
610
|
+
use_ssl = target.scheme == "https"
|
|
611
|
+
|
|
612
|
+
self._apply_credentials(request)
|
|
613
|
+
if self.use_cookies:
|
|
614
|
+
self.cookies.add_cookie_header(_CookieJarRequestAdapter(request))
|
|
615
|
+
if "Accept-Encoding" not in request.headers:
|
|
616
|
+
request.headers["Accept-Encoding"] = "gzip, deflate"
|
|
617
|
+
|
|
618
|
+
is_streamed_upload = isinstance(request.content, StreamContent)
|
|
619
|
+
if is_streamed_upload and request.content.is_chunked and version != HttpVersion.HTTP_1_1:
|
|
620
|
+
raise ValueError("Chunked transfer encoding (StreamContent without a known length) requires HTTP/1.1.")
|
|
621
|
+
|
|
622
|
+
outgoing_headers = HttpHeaders()
|
|
623
|
+
outgoing_headers.update(request.headers)
|
|
624
|
+
if request.content is not None:
|
|
625
|
+
outgoing_headers.update(request.content.headers)
|
|
626
|
+
|
|
627
|
+
body = await request.content._open_wire_body() if request.content else None
|
|
628
|
+
chunked = bool(request.content and request.content.is_chunked)
|
|
629
|
+
path = (target.path or "/") + ("?" + target.query if target.query else "")
|
|
630
|
+
|
|
631
|
+
def send_request():
|
|
632
|
+
return self._send_sync(target, use_ssl, request.method, path, outgoing_headers, body, chunked, version, timeout, completion_option, is_streamed_upload)
|
|
633
|
+
|
|
634
|
+
pool_key, _ = self._connection_key_and_path(target, use_ssl, path)
|
|
635
|
+
semaphore = self._get_semaphore(pool_key)
|
|
636
|
+
if semaphore is not None:
|
|
637
|
+
await semaphore.acquire()
|
|
638
|
+
try:
|
|
639
|
+
message = await asyncio.get_event_loop().run_in_executor(None, send_request)
|
|
640
|
+
finally:
|
|
641
|
+
if semaphore is not None:
|
|
642
|
+
semaphore.release()
|
|
643
|
+
|
|
644
|
+
message.request_message = request
|
|
645
|
+
if self.use_cookies:
|
|
646
|
+
self.cookies.extract_cookies(_CookieJarResponseAdapter(message.headers), _CookieJarRequestAdapter(request))
|
|
647
|
+
return message
|
|
648
|
+
|
|
649
|
+
async def send_async(
|
|
650
|
+
self,
|
|
651
|
+
request: HttpRequestMessage,
|
|
652
|
+
timeout: Optional[float] = None,
|
|
653
|
+
completion_option: str = HttpCompletionOption.RESPONSE_CONTENT_READ,
|
|
654
|
+
) -> HttpResponseMessage:
|
|
655
|
+
current_request = request
|
|
656
|
+
response = None
|
|
657
|
+
|
|
658
|
+
for _ in range(self.max_automatic_redirects + 1):
|
|
659
|
+
response = await self._send_once_async(current_request, timeout, completion_option)
|
|
660
|
+
|
|
661
|
+
if not self.allow_auto_redirect or response.status_code not in _REDIRECT_STATUS_CODES:
|
|
662
|
+
return response
|
|
663
|
+
|
|
664
|
+
location = response.headers.get("Location")
|
|
665
|
+
if not location:
|
|
666
|
+
return response
|
|
667
|
+
|
|
668
|
+
if response.content is not None:
|
|
669
|
+
await response.content.dispose_async()
|
|
670
|
+
|
|
671
|
+
next_uri = urljoin(current_request.request_uri, location)
|
|
672
|
+
current_scheme = urlparse(current_request.request_uri).scheme
|
|
673
|
+
if current_scheme == "https" and urlparse(next_uri).scheme == "http":
|
|
674
|
+
return response # refuse to silently downgrade from HTTPS to plaintext
|
|
675
|
+
|
|
676
|
+
next_method = current_request.method
|
|
677
|
+
next_content = current_request.content
|
|
678
|
+
if response.status_code in _METHOD_DOWNGRADE_STATUS_CODES and current_request.method not in (HttpMethod.GET, HttpMethod.HEAD):
|
|
679
|
+
next_method = HttpMethod.GET
|
|
680
|
+
next_content = None
|
|
681
|
+
elif response.status_code in _BODY_PRESERVING_REDIRECT_STATUS_CODES and isinstance(next_content, StreamContent):
|
|
682
|
+
raise RuntimeError(
|
|
683
|
+
f"Cannot automatically replay a StreamContent request body across a {response.status_code} "
|
|
684
|
+
"redirect; the stream has already been consumed and cannot be rewound. Disable "
|
|
685
|
+
"allow_auto_redirect or avoid streamed request bodies against redirect-prone endpoints."
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
next_request = HttpRequestMessage(next_method, next_uri, next_content, current_request.version)
|
|
689
|
+
same_host = urlparse(next_uri).hostname == urlparse(current_request.request_uri).hostname
|
|
690
|
+
for name in current_request.headers:
|
|
691
|
+
lower = name.lower()
|
|
692
|
+
if lower in _ALWAYS_STRIP_REDIRECT_HEADERS:
|
|
693
|
+
continue
|
|
694
|
+
if not same_host and lower in _CROSS_HOST_STRIP_REDIRECT_HEADERS:
|
|
695
|
+
continue
|
|
696
|
+
for value in current_request.headers.get_all(name):
|
|
697
|
+
next_request.headers.add(name, value)
|
|
698
|
+
|
|
699
|
+
current_request = next_request
|
|
700
|
+
|
|
701
|
+
return response
|
|
702
|
+
|
|
703
|
+
def close(self):
|
|
704
|
+
self._pool.close_all()
|
|
705
|
+
|
|
706
|
+
def cancel_pending_requests(self):
|
|
707
|
+
self._pool.cancel_in_flight()
|
|
708
|
+
|
|
709
|
+
class HttpClient:
|
|
710
|
+
def __init__(self, handler: HttpMessageHandler = None):
|
|
711
|
+
self.base_address: Optional[str] = None
|
|
712
|
+
self.default_request_headers = HttpHeaders()
|
|
713
|
+
# None means each new HttpRequestMessage falls back to HttpVersion.HTTP_1_1.
|
|
714
|
+
self.default_request_version: Optional[str] = None
|
|
715
|
+
self.timeout: Optional[float] = 100.0
|
|
716
|
+
self.handler = handler or HttpClientHandler()
|
|
717
|
+
|
|
718
|
+
async def send_async(
|
|
719
|
+
self,
|
|
720
|
+
request: HttpRequestMessage,
|
|
721
|
+
completion_option: str = HttpCompletionOption.RESPONSE_CONTENT_READ,
|
|
722
|
+
) -> HttpResponseMessage:
|
|
723
|
+
if self.base_address:
|
|
724
|
+
request.request_uri = urljoin(self.base_address, request.request_uri)
|
|
725
|
+
|
|
726
|
+
if request.version is None:
|
|
727
|
+
request.version = self.default_request_version
|
|
728
|
+
|
|
729
|
+
for name in self.default_request_headers:
|
|
730
|
+
if name not in request.headers:
|
|
731
|
+
for value in self.default_request_headers.get_all(name):
|
|
732
|
+
request.headers.add(name, value)
|
|
733
|
+
|
|
734
|
+
if self.timeout is None:
|
|
735
|
+
return await self.handler.send_async(request, timeout=None, completion_option=completion_option)
|
|
736
|
+
|
|
737
|
+
try:
|
|
738
|
+
return await asyncio.wait_for(
|
|
739
|
+
self.handler.send_async(request, timeout=self.timeout, completion_option=completion_option),
|
|
740
|
+
timeout=self.timeout,
|
|
741
|
+
)
|
|
742
|
+
except asyncio.TimeoutError as exc:
|
|
743
|
+
raise TimeoutError(f"The request timed out after {self.timeout} seconds.") from exc
|
|
744
|
+
|
|
745
|
+
async def get_async(self, request_uri: str) -> HttpResponseMessage:
|
|
746
|
+
request = HttpRequestMessage(HttpMethod.GET, request_uri)
|
|
747
|
+
return await self.send_async(request)
|
|
748
|
+
|
|
749
|
+
async def get_byte_array_async(self, request_uri: str) -> bytes:
|
|
750
|
+
response = await self.get_async(request_uri)
|
|
751
|
+
return await response.content.read_as_bytes_async()
|
|
752
|
+
|
|
753
|
+
async def get_string_async(self, request_uri: str) -> str:
|
|
754
|
+
response = await self.get_async(request_uri)
|
|
755
|
+
return await response.content.read_as_string_async()
|
|
756
|
+
|
|
757
|
+
async def get_stream_async(self, request_uri: str) -> AsyncIterator[bytes]:
|
|
758
|
+
request = HttpRequestMessage(HttpMethod.GET, request_uri)
|
|
759
|
+
response = await self.send_async(request, completion_option=HttpCompletionOption.RESPONSE_HEADERS_READ)
|
|
760
|
+
async for chunk in response.content.stream_async():
|
|
761
|
+
yield chunk
|
|
762
|
+
|
|
763
|
+
async def post_async(self, request_uri: str, content: Optional[HttpContent] = None) -> HttpResponseMessage:
|
|
764
|
+
request = HttpRequestMessage(HttpMethod.POST, request_uri, content)
|
|
765
|
+
return await self.send_async(request)
|
|
766
|
+
|
|
767
|
+
async def put_async(self, request_uri: str, content: Optional[HttpContent] = None) -> HttpResponseMessage:
|
|
768
|
+
request = HttpRequestMessage(HttpMethod.PUT, request_uri, content)
|
|
769
|
+
return await self.send_async(request)
|
|
770
|
+
|
|
771
|
+
async def delete_async(self, request_uri: str) -> HttpResponseMessage:
|
|
772
|
+
request = HttpRequestMessage(HttpMethod.DELETE, request_uri)
|
|
773
|
+
return await self.send_async(request)
|
|
774
|
+
|
|
775
|
+
def set_base_address(self, url: str):
|
|
776
|
+
self.base_address = url
|
|
777
|
+
|
|
778
|
+
def default_request_headers_add(self, name: str, value: str):
|
|
779
|
+
self.default_request_headers.add(name, value)
|
|
780
|
+
|
|
781
|
+
def cancel_pending_requests(self):
|
|
782
|
+
self.handler.cancel_pending_requests()
|
|
783
|
+
|
|
784
|
+
def close(self):
|
|
785
|
+
self.handler.close()
|
|
786
|
+
|
|
787
|
+
async def __aenter__(self):
|
|
788
|
+
return self
|
|
789
|
+
|
|
790
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
791
|
+
self.close()
|
|
792
|
+
|
|
793
|
+
class HttpContent:
|
|
794
|
+
def __init__(self):
|
|
795
|
+
self.headers = HttpHeaders()
|
|
796
|
+
|
|
797
|
+
@property
|
|
798
|
+
def is_chunked(self) -> bool:
|
|
799
|
+
return False
|
|
800
|
+
|
|
801
|
+
async def read_as_bytes_async(self) -> bytes:
|
|
802
|
+
raise NotImplementedError("Derived classes must implement read_as_bytes_async")
|
|
803
|
+
|
|
804
|
+
async def read_as_string_async(self) -> str:
|
|
805
|
+
return (await self.read_as_bytes_async()).decode("utf-8")
|
|
806
|
+
|
|
807
|
+
async def read_as_json_async(self) -> Any:
|
|
808
|
+
return json.loads(await self.read_as_string_async())
|
|
809
|
+
|
|
810
|
+
async def dispose_async(self):
|
|
811
|
+
pass
|
|
812
|
+
|
|
813
|
+
async def _open_wire_body(self):
|
|
814
|
+
"""What to hand http.client as the request body: raw bytes by default."""
|
|
815
|
+
return await self.read_as_bytes_async()
|
|
816
|
+
|
|
817
|
+
class ByteArrayContent(HttpContent):
|
|
818
|
+
def __init__(self, data: bytes, media_type: str = "application/octet-stream"):
|
|
819
|
+
super().__init__()
|
|
820
|
+
self._data = bytes(data)
|
|
821
|
+
self.headers["Content-Type"] = media_type
|
|
822
|
+
self.headers["Content-Length"] = str(len(self._data))
|
|
823
|
+
|
|
824
|
+
async def read_as_bytes_async(self) -> bytes:
|
|
825
|
+
return self._data
|
|
826
|
+
|
|
827
|
+
def get_length(self) -> int:
|
|
828
|
+
return len(self._data)
|
|
829
|
+
|
|
830
|
+
class StringContent(ByteArrayContent):
|
|
831
|
+
def __init__(self, text: str, media_type: str = "text/plain", encoding: str = "utf-8"):
|
|
832
|
+
super().__init__(text.encode(encoding), f"{media_type}; charset={encoding}")
|
|
833
|
+
|
|
834
|
+
class JsonContent(ByteArrayContent):
|
|
835
|
+
def __init__(self, value: Any, encoding: str = "utf-8"):
|
|
836
|
+
super().__init__(json.dumps(value).encode(encoding), f"application/json; charset={encoding}")
|
|
837
|
+
|
|
838
|
+
@classmethod
|
|
839
|
+
def create(cls, value: Any) -> JsonContent:
|
|
840
|
+
return cls(value)
|
|
841
|
+
|
|
842
|
+
class FormUrlEncodedContent(ByteArrayContent):
|
|
843
|
+
def __init__(self, data: Union[dict, list]):
|
|
844
|
+
pairs = data.items() if isinstance(data, dict) else data
|
|
845
|
+
super().__init__(urlencode(list(pairs)).encode("ascii"), "application/x-www-form-urlencoded")
|
|
846
|
+
|
|
847
|
+
class StreamContent(HttpContent):
|
|
848
|
+
"""Content backed by a file-like object or byte-chunk iterable, sent without fully
|
|
849
|
+
buffering it in memory first (mirrors System.Net.Http.StreamContent).
|
|
850
|
+
|
|
851
|
+
If length is omitted, the body is sent with Transfer-Encoding: chunked, which
|
|
852
|
+
requires HTTP/1.1. A StreamContent instance can only be sent once - the underlying
|
|
853
|
+
stream can't be rewound for retries or 307/308 redirect replay.
|
|
854
|
+
"""
|
|
855
|
+
|
|
856
|
+
def __init__(self, stream, media_type: str = "application/octet-stream", length: Optional[int] = None):
|
|
857
|
+
super().__init__()
|
|
858
|
+
self._stream = stream
|
|
859
|
+
self.headers["Content-Type"] = media_type
|
|
860
|
+
if length is not None:
|
|
861
|
+
self.headers["Content-Length"] = str(length)
|
|
862
|
+
|
|
863
|
+
@property
|
|
864
|
+
def is_chunked(self) -> bool:
|
|
865
|
+
return "Content-Length" not in self.headers
|
|
866
|
+
|
|
867
|
+
async def read_as_bytes_async(self) -> bytes:
|
|
868
|
+
data = self._stream.read()
|
|
869
|
+
return data if isinstance(data, bytes) else bytes(data)
|
|
870
|
+
|
|
871
|
+
async def _open_wire_body(self):
|
|
872
|
+
return self._stream
|
|
873
|
+
|
|
874
|
+
class _ResponseStreamContent(HttpContent):
|
|
875
|
+
"""HttpContent backed by a live, not-yet-fully-read HTTPResponse.
|
|
876
|
+
Produced when HttpCompletionOption.RESPONSE_HEADERS_READ is used. Not part of the
|
|
877
|
+
public API - callers only see it as an HttpContent via HttpResponseMessage.content.
|
|
878
|
+
"""
|
|
879
|
+
|
|
880
|
+
def __init__(self, response: http.client.HTTPResponse, content_encoding: Optional[str], on_complete: Callable[[bool], None]):
|
|
881
|
+
super().__init__()
|
|
882
|
+
self._response = response
|
|
883
|
+
self._decompressor = _StreamDecompressor(content_encoding)
|
|
884
|
+
self._on_complete = on_complete
|
|
885
|
+
self._done = False
|
|
886
|
+
|
|
887
|
+
def _finish(self, aborted: bool):
|
|
888
|
+
if not self._done:
|
|
889
|
+
self._done = True
|
|
890
|
+
self._on_complete(aborted)
|
|
891
|
+
|
|
892
|
+
async def stream_async(self, chunk_size: int = 8192) -> AsyncIterator[bytes]:
|
|
893
|
+
loop = asyncio.get_event_loop()
|
|
894
|
+
aborted = True
|
|
895
|
+
try:
|
|
896
|
+
while True:
|
|
897
|
+
raw = await loop.run_in_executor(None, self._response.read, chunk_size)
|
|
898
|
+
if not raw:
|
|
899
|
+
break
|
|
900
|
+
data = self._decompressor.decompress(raw)
|
|
901
|
+
if data:
|
|
902
|
+
yield data
|
|
903
|
+
tail = self._decompressor.flush()
|
|
904
|
+
if tail:
|
|
905
|
+
yield tail
|
|
906
|
+
aborted = False
|
|
907
|
+
finally:
|
|
908
|
+
self._finish(aborted)
|
|
909
|
+
|
|
910
|
+
async def read_as_bytes_async(self) -> bytes:
|
|
911
|
+
chunks = [chunk async for chunk in self.stream_async()]
|
|
912
|
+
return b"".join(chunks)
|
|
913
|
+
|
|
914
|
+
async def dispose_async(self):
|
|
915
|
+
self._finish(aborted=True)
|
|
916
|
+
|
|
917
|
+
class HttpRequestMessage:
|
|
918
|
+
def __init__(
|
|
919
|
+
self,
|
|
920
|
+
method: str = HttpMethod.GET,
|
|
921
|
+
request_uri: Optional[str] = None,
|
|
922
|
+
content: Optional[HttpContent] = None,
|
|
923
|
+
version: Optional[str] = None,
|
|
924
|
+
):
|
|
925
|
+
self.method = method
|
|
926
|
+
self.request_uri = request_uri
|
|
927
|
+
self._content: Optional[HttpContent] = None
|
|
928
|
+
self.headers = HttpHeaders()
|
|
929
|
+
# None means "resolve to HttpClient.default_request_version, else HTTP/1.1" at send time.
|
|
930
|
+
self.version = version
|
|
931
|
+
# Free-form per-request metadata bag for custom handlers/middleware to use.
|
|
932
|
+
self.options: dict[str, Any] = {}
|
|
933
|
+
self.set_content(content)
|
|
934
|
+
|
|
935
|
+
@property
|
|
936
|
+
def content(self) -> Optional[HttpContent]:
|
|
937
|
+
return self._content
|
|
938
|
+
|
|
939
|
+
def set_content(self, content: Optional[HttpContent]):
|
|
940
|
+
if content is not None and not isinstance(content, HttpContent):
|
|
941
|
+
raise TypeError("content must be an HttpContent instance (e.g. StringContent, JsonContent, ByteArrayContent) or None.")
|
|
942
|
+
self._content = content
|
|
943
|
+
|
|
944
|
+
@property
|
|
945
|
+
def request_uri(self) -> Optional[str]:
|
|
946
|
+
return self._request_uri
|
|
947
|
+
|
|
948
|
+
@request_uri.setter
|
|
949
|
+
def request_uri(self, value: str):
|
|
950
|
+
self._request_uri = value
|
|
951
|
+
if value:
|
|
952
|
+
parsed = urlparse(value)
|
|
953
|
+
if parsed.scheme and parsed.netloc:
|
|
954
|
+
self._request_uri = value
|
|
955
|
+
else:
|
|
956
|
+
self._request_uri = parsed.path + ("?" + parsed.query if parsed.query else "")
|
|
957
|
+
|
|
958
|
+
def headers_get(self, name: str) -> Optional[str]:
|
|
959
|
+
return self.headers.get(name)
|
|
960
|
+
|
|
961
|
+
def headers_add(self, name: str, value: str):
|
|
962
|
+
self.headers.add(name, value)
|
|
963
|
+
|
|
964
|
+
def headers_remove(self, name: str):
|
|
965
|
+
self.headers.remove(name)
|
|
966
|
+
|
|
967
|
+
def to_string(self) -> str:
|
|
968
|
+
parts = [
|
|
969
|
+
f"{self.method} {self.request_uri or '/'} HTTP/{self.version or HttpVersion.HTTP_1_1}",
|
|
970
|
+
*[f"{k}: {v}" for k, v in self.headers.items()],
|
|
971
|
+
"",
|
|
972
|
+
str(self.content) if self.content else ""
|
|
973
|
+
]
|
|
974
|
+
return "\r\n".join(parts)
|
|
975
|
+
|
|
976
|
+
class HttpResponseMessage:
|
|
977
|
+
@classmethod
|
|
978
|
+
def from_http_response(cls, response: http.client.HTTPResponse, max_size: Optional[int] = None) -> HttpResponseMessage:
|
|
979
|
+
message = cls(response.status)
|
|
980
|
+
raw_body = _read_bounded(response, max_size)
|
|
981
|
+
content_encoding = response.getheader("Content-Encoding")
|
|
982
|
+
|
|
983
|
+
message.headers.update(response.headers)
|
|
984
|
+
if content_encoding:
|
|
985
|
+
raw_body = _decompress_content(raw_body, content_encoding)
|
|
986
|
+
message.headers.remove("Content-Encoding")
|
|
987
|
+
message.headers.remove("Content-Length")
|
|
988
|
+
|
|
989
|
+
message.content = ByteArrayContent(raw_body)
|
|
990
|
+
message.reason_phrase = response.reason
|
|
991
|
+
message.version = f"{response.version // 10}.{response.version % 10}"
|
|
992
|
+
return message
|
|
993
|
+
|
|
994
|
+
def __init__(self, status_code: int = HttpStatusCode.OK):
|
|
995
|
+
self.status_code = status_code
|
|
996
|
+
self.content: Optional[HttpContent] = None
|
|
997
|
+
self.headers = HttpHeaders()
|
|
998
|
+
self.reason_phrase: Optional[str] = None
|
|
999
|
+
self.version: str = HttpVersion.HTTP_1_1
|
|
1000
|
+
self.request_message: Optional[HttpRequestMessage] = None
|
|
1001
|
+
|
|
1002
|
+
def is_success_status_code(self) -> bool:
|
|
1003
|
+
return 200 <= self.status_code < 300
|
|
1004
|
+
|
|
1005
|
+
def ensure_success_status_code(self):
|
|
1006
|
+
if not self.is_success_status_code():
|
|
1007
|
+
raise HttpRequestException(self)
|
|
1008
|
+
|
|
1009
|
+
def __str__(self):
|
|
1010
|
+
return f"HTTP/{self.version} {self.status_code} {self.reason_phrase}"
|
|
1011
|
+
|
|
1012
|
+
class HttpRequestException(Exception):
|
|
1013
|
+
def __init__(self, response: HttpResponseMessage):
|
|
1014
|
+
self.response = response
|
|
1015
|
+
super().__init__(f"Response status code does not indicate success: {response.status_code} ({response.reason_phrase})")
|
|
1016
|
+
|
|
1017
|
+
class HttpResponseReceivedEventArgs:
|
|
1018
|
+
def __init__(self, response: HttpResponseMessage):
|
|
1019
|
+
self.response = response
|
|
1020
|
+
|
|
1021
|
+
class HttpServiceClient:
|
|
1022
|
+
def __init__(self):
|
|
1023
|
+
self._http_client: Optional[HttpClient] = None
|
|
1024
|
+
self.http_response_received: Optional[Callable[[HttpResponseMessage], None]] = None
|
|
1025
|
+
|
|
1026
|
+
@property
|
|
1027
|
+
def http_client(self) -> HttpClient:
|
|
1028
|
+
if self._http_client is None:
|
|
1029
|
+
self._http_client = self.create_http_client()
|
|
1030
|
+
return self._http_client
|
|
1031
|
+
|
|
1032
|
+
def create_http_client(self) -> HttpClient:
|
|
1033
|
+
return HttpClient()
|
|
1034
|
+
|
|
1035
|
+
async def pre_process_http_request_message_async(self, request: HttpRequestMessage):
|
|
1036
|
+
pass
|
|
1037
|
+
|
|
1038
|
+
async def post_process_http_request_message_async(self, request: HttpRequestMessage):
|
|
1039
|
+
pass
|
|
1040
|
+
|
|
1041
|
+
async def process_http_response_message_async(self, response: HttpResponseMessage):
|
|
1042
|
+
pass
|
|
1043
|
+
|
|
1044
|
+
async def handle_success_status_code_async(self, response: HttpResponseMessage):
|
|
1045
|
+
pass
|
|
1046
|
+
|
|
1047
|
+
async def handle_non_success_status_code_async(self, response: HttpResponseMessage):
|
|
1048
|
+
pass
|
|
1049
|
+
|
|
1050
|
+
async def get_async(self, request_uri: str) -> HttpResponseMessage:
|
|
1051
|
+
return await self.execute_async(HttpMethod.GET, request_uri)
|
|
1052
|
+
|
|
1053
|
+
async def get_byte_array_async(self, request_uri: str) -> bytes:
|
|
1054
|
+
response = await self.get_async(request_uri)
|
|
1055
|
+
return await response.content.read_as_bytes_async()
|
|
1056
|
+
|
|
1057
|
+
async def get_string_async(self, request_uri: str) -> str:
|
|
1058
|
+
response = await self.get_async(request_uri)
|
|
1059
|
+
return await response.content.read_as_string_async()
|
|
1060
|
+
|
|
1061
|
+
async def get_stream_async(self, request_uri: str) -> AsyncIterator[bytes]:
|
|
1062
|
+
response = await self.execute_async(HttpMethod.GET, request_uri, completion_option=HttpCompletionOption.RESPONSE_HEADERS_READ)
|
|
1063
|
+
async for chunk in response.content.stream_async():
|
|
1064
|
+
yield chunk
|
|
1065
|
+
|
|
1066
|
+
async def post_async(self, request_uri: str, content: Optional[HttpContent] = None) -> HttpResponseMessage:
|
|
1067
|
+
return await self.execute_async(HttpMethod.POST, request_uri, content)
|
|
1068
|
+
|
|
1069
|
+
async def put_async(self, request_uri: str, content: Optional[HttpContent] = None) -> HttpResponseMessage:
|
|
1070
|
+
return await self.execute_async(HttpMethod.PUT, request_uri, content)
|
|
1071
|
+
|
|
1072
|
+
async def patch_async(self, request_uri: str, content: Optional[HttpContent] = None) -> HttpResponseMessage:
|
|
1073
|
+
return await self.execute_async(HttpMethod.PATCH, request_uri, content)
|
|
1074
|
+
|
|
1075
|
+
async def delete_async(self, request_uri: str, content: Optional[HttpContent] = None) -> HttpResponseMessage:
|
|
1076
|
+
return await self.execute_async(HttpMethod.DELETE, request_uri, content)
|
|
1077
|
+
|
|
1078
|
+
async def execute_async(
|
|
1079
|
+
self,
|
|
1080
|
+
method: str,
|
|
1081
|
+
request_uri: str,
|
|
1082
|
+
content: Optional[HttpContent] = None,
|
|
1083
|
+
http_request_message_modifier: Optional[Callable[[HttpRequestMessage], None]] = None,
|
|
1084
|
+
completion_option: str = HttpCompletionOption.RESPONSE_CONTENT_READ,
|
|
1085
|
+
) -> HttpResponseMessage:
|
|
1086
|
+
request = HttpRequestMessage(method, request_uri, content)
|
|
1087
|
+
|
|
1088
|
+
await self.pre_process_http_request_message_async(request)
|
|
1089
|
+
if http_request_message_modifier:
|
|
1090
|
+
http_request_message_modifier(request)
|
|
1091
|
+
await self.post_process_http_request_message_async(request)
|
|
1092
|
+
|
|
1093
|
+
response = await self.http_client.send_async(request, completion_option=completion_option)
|
|
1094
|
+
|
|
1095
|
+
if self.http_response_received:
|
|
1096
|
+
self.http_response_received(HttpResponseReceivedEventArgs(response))
|
|
1097
|
+
|
|
1098
|
+
await self.process_http_response_message_async(response)
|
|
1099
|
+
|
|
1100
|
+
if response.is_success_status_code():
|
|
1101
|
+
await self.handle_success_status_code_async(response)
|
|
1102
|
+
else:
|
|
1103
|
+
await self.handle_non_success_status_code_async(response)
|
|
1104
|
+
|
|
1105
|
+
return response
|
|
1106
|
+
|
|
1107
|
+
async def send_async(
|
|
1108
|
+
self,
|
|
1109
|
+
request: HttpRequestMessage,
|
|
1110
|
+
completion_option: str = HttpCompletionOption.RESPONSE_CONTENT_READ,
|
|
1111
|
+
) -> HttpResponseMessage:
|
|
1112
|
+
await self.pre_process_http_request_message_async(request)
|
|
1113
|
+
await self.post_process_http_request_message_async(request)
|
|
1114
|
+
|
|
1115
|
+
response = await self.http_client.send_async(request, completion_option=completion_option)
|
|
1116
|
+
|
|
1117
|
+
if self.http_response_received:
|
|
1118
|
+
self.http_response_received(HttpResponseReceivedEventArgs(response))
|
|
1119
|
+
|
|
1120
|
+
await self.process_http_response_message_async(response)
|
|
1121
|
+
|
|
1122
|
+
if response.is_success_status_code():
|
|
1123
|
+
await self.handle_success_status_code_async(response)
|
|
1124
|
+
else:
|
|
1125
|
+
await self.handle_non_success_status_code_async(response)
|
|
1126
|
+
|
|
1127
|
+
return response
|
|
1128
|
+
|
|
1129
|
+
async def __aenter__(self):
|
|
1130
|
+
return self
|
|
1131
|
+
|
|
1132
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
1133
|
+
if self._http_client is not None:
|
|
1134
|
+
self._http_client.close()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: universal-common-net-http
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Library for HTTP operations.
|
|
5
|
+
Author-email: Andrew Ong <ong.andrew@gmail.com>
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
8
|
+
Classifier: License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest; extra == 'test'
|
|
14
|
+
Requires-Dist: pytest-asyncio; extra == 'test'
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
universal_common_net_http/__init__.py,sha256=nSntwpGzJ5-BWw6rak_iqfQw3pSo5969X1_fXmUA3Es,43701
|
|
2
|
+
universal_common_net_http-1.0.0.dist-info/METADATA,sha256=TEatbSzwi7aZkC2h0oncDYYggjPIG_FoAByk3VzWiXc,534
|
|
3
|
+
universal_common_net_http-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
4
|
+
universal_common_net_http-1.0.0.dist-info/licenses/LICENSE,sha256=oZ1YqqsVxNABnladHAc9G1KG_dN9vu56WKfRrnYEWuE,7167
|
|
5
|
+
universal_common_net_http-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Creative Commons Legal Code
|
|
2
|
+
|
|
3
|
+
CC0 1.0 Universal
|
|
4
|
+
|
|
5
|
+
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
|
6
|
+
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
|
|
7
|
+
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
|
8
|
+
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
|
9
|
+
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
|
|
10
|
+
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
|
|
11
|
+
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
|
|
12
|
+
HEREUNDER.
|
|
13
|
+
|
|
14
|
+
Statement of Purpose
|
|
15
|
+
|
|
16
|
+
The laws of most jurisdictions throughout the world automatically confer
|
|
17
|
+
exclusive Copyright and Related Rights (defined below) upon the creator
|
|
18
|
+
and subsequent owner(s) (each and all, an "owner") of an original work of
|
|
19
|
+
authorship and/or a database (each, a "Work").
|
|
20
|
+
|
|
21
|
+
Certain owners wish to permanently relinquish those rights to a Work for
|
|
22
|
+
the purpose of contributing to a commons of creative, cultural and
|
|
23
|
+
scientific works ("Commons") that the public can reliably and without fear
|
|
24
|
+
of later claims of infringement build upon, modify, incorporate in other
|
|
25
|
+
works, reuse and redistribute as freely as possible in any form whatsoever
|
|
26
|
+
and for any purposes, including without limitation commercial purposes.
|
|
27
|
+
These owners may contribute to the Commons to promote the ideal of a free
|
|
28
|
+
culture and the further production of creative, cultural and scientific
|
|
29
|
+
works, or to gain reputation or greater distribution for their Work in
|
|
30
|
+
part through the use and efforts of others.
|
|
31
|
+
|
|
32
|
+
For these and/or other purposes and motivations, and without any
|
|
33
|
+
expectation of additional consideration or compensation, the person
|
|
34
|
+
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
|
|
35
|
+
is an owner of Copyright and Related Rights in the Work, voluntarily
|
|
36
|
+
elects to apply CC0 to the Work and publicly distribute the Work under its
|
|
37
|
+
terms, with knowledge of his or her Copyright and Related Rights in the
|
|
38
|
+
Work and the meaning and intended legal effect of CC0 on those rights.
|
|
39
|
+
|
|
40
|
+
1. Copyright and Related Rights. A Work made available under CC0 may be
|
|
41
|
+
protected by copyright and related or neighboring rights ("Copyright and
|
|
42
|
+
Related Rights"). Copyright and Related Rights include, but are not
|
|
43
|
+
limited to, the following:
|
|
44
|
+
|
|
45
|
+
i. the right to reproduce, adapt, distribute, perform, display,
|
|
46
|
+
communicate, and translate a Work;
|
|
47
|
+
ii. moral rights retained by the original author(s) and/or performer(s);
|
|
48
|
+
iii. publicity and privacy rights pertaining to a person's image or
|
|
49
|
+
likeness depicted in a Work;
|
|
50
|
+
iv. rights protecting against unfair competition in regards to a Work,
|
|
51
|
+
subject to the limitations in paragraph 4(a), below;
|
|
52
|
+
v. rights protecting the extraction, dissemination, use and reuse of data
|
|
53
|
+
in a Work;
|
|
54
|
+
vi. database rights (such as those arising under Directive 96/9/EC of the
|
|
55
|
+
European Parliament and of the Council of 11 March 1996 on the legal
|
|
56
|
+
protection of databases, and under any national implementation
|
|
57
|
+
thereof, including any amended or successor version of such
|
|
58
|
+
directive); and
|
|
59
|
+
vii. other similar, equivalent or corresponding rights throughout the
|
|
60
|
+
world based on applicable law or treaty, and any national
|
|
61
|
+
implementations thereof.
|
|
62
|
+
|
|
63
|
+
2. Waiver. To the greatest extent permitted by, but not in contravention
|
|
64
|
+
of, applicable law, Affirmer hereby overtly, fully, permanently,
|
|
65
|
+
irrevocably and unconditionally waives, abandons, and surrenders all of
|
|
66
|
+
Affirmer's Copyright and Related Rights and associated claims and causes
|
|
67
|
+
of action, whether now known or unknown (including existing as well as
|
|
68
|
+
future claims and causes of action), in the Work (i) in all territories
|
|
69
|
+
worldwide, (ii) for the maximum duration provided by applicable law or
|
|
70
|
+
treaty (including future time extensions), (iii) in any current or future
|
|
71
|
+
medium and for any number of copies, and (iv) for any purpose whatsoever,
|
|
72
|
+
including without limitation commercial, advertising or promotional
|
|
73
|
+
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
|
|
74
|
+
member of the public at large and to the detriment of Affirmer's heirs and
|
|
75
|
+
successors, fully intending that such Waiver shall not be subject to
|
|
76
|
+
revocation, rescission, cancellation, termination, or any other legal or
|
|
77
|
+
equitable action to disrupt the quiet enjoyment of the Work by the public
|
|
78
|
+
as contemplated by Affirmer's express Statement of Purpose.
|
|
79
|
+
|
|
80
|
+
3. Public License Fallback. Should any part of the Waiver for any reason
|
|
81
|
+
be judged legally invalid or ineffective under applicable law, then the
|
|
82
|
+
Waiver shall be preserved to the maximum extent permitted taking into
|
|
83
|
+
account Affirmer's express Statement of Purpose. In addition, to the
|
|
84
|
+
extent the Waiver is so judged Affirmer hereby grants to each affected
|
|
85
|
+
person a royalty-free, non transferable, non sublicensable, non exclusive,
|
|
86
|
+
irrevocable and unconditional license to exercise Affirmer's Copyright and
|
|
87
|
+
Related Rights in the Work (i) in all territories worldwide, (ii) for the
|
|
88
|
+
maximum duration provided by applicable law or treaty (including future
|
|
89
|
+
time extensions), (iii) in any current or future medium and for any number
|
|
90
|
+
of copies, and (iv) for any purpose whatsoever, including without
|
|
91
|
+
limitation commercial, advertising or promotional purposes (the
|
|
92
|
+
"License"). The License shall be deemed effective as of the date CC0 was
|
|
93
|
+
applied by Affirmer to the Work. Should any part of the License for any
|
|
94
|
+
reason be judged legally invalid or ineffective under applicable law, such
|
|
95
|
+
partial invalidity or ineffectiveness shall not invalidate the remainder
|
|
96
|
+
of the License, and in such case Affirmer hereby affirms that he or she
|
|
97
|
+
will not (i) exercise any of his or her remaining Copyright and Related
|
|
98
|
+
Rights in the Work or (ii) assert any associated claims and causes of
|
|
99
|
+
action with respect to the Work, in either case contrary to Affirmer's
|
|
100
|
+
express Statement of Purpose.
|
|
101
|
+
|
|
102
|
+
4. Limitations and Disclaimers.
|
|
103
|
+
|
|
104
|
+
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
|
105
|
+
surrendered, licensed or otherwise affected by this document.
|
|
106
|
+
b. Affirmer offers the Work as-is and makes no representations or
|
|
107
|
+
warranties of any kind concerning the Work, express, implied,
|
|
108
|
+
statutory or otherwise, including without limitation warranties of
|
|
109
|
+
title, merchantability, fitness for a particular purpose, non
|
|
110
|
+
infringement, or the absence of latent or other defects, accuracy, or
|
|
111
|
+
the present or absence of errors, whether or not discoverable, all to
|
|
112
|
+
the greatest extent permissible under applicable law.
|
|
113
|
+
c. Affirmer disclaims responsibility for clearing rights of other persons
|
|
114
|
+
that may apply to the Work or any use thereof, including without
|
|
115
|
+
limitation any person's Copyright and Related Rights in the Work.
|
|
116
|
+
Further, Affirmer disclaims responsibility for obtaining any necessary
|
|
117
|
+
consents, permissions or other rights required for any use of the
|
|
118
|
+
Work.
|
|
119
|
+
d. Affirmer understands and acknowledges that Creative Commons is not a
|
|
120
|
+
party to this document and has no duty or obligation with respect to
|
|
121
|
+
this CC0 or use of the Work.
|