dt-extensions-sdk 1.1.22__py3-none-any.whl → 1.1.24__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.
- {dt_extensions_sdk-1.1.22.dist-info → dt_extensions_sdk-1.1.24.dist-info}/METADATA +2 -2
- dt_extensions_sdk-1.1.24.dist-info/RECORD +33 -0
- {dt_extensions_sdk-1.1.22.dist-info → dt_extensions_sdk-1.1.24.dist-info}/WHEEL +1 -1
- {dt_extensions_sdk-1.1.22.dist-info → dt_extensions_sdk-1.1.24.dist-info}/licenses/LICENSE.txt +9 -9
- dynatrace_extension/__about__.py +5 -5
- dynatrace_extension/__init__.py +27 -27
- dynatrace_extension/cli/__init__.py +5 -5
- dynatrace_extension/cli/create/__init__.py +1 -1
- dynatrace_extension/cli/create/create.py +76 -76
- dynatrace_extension/cli/create/extension_template/.gitignore.template +160 -160
- dynatrace_extension/cli/create/extension_template/README.md.template +33 -33
- dynatrace_extension/cli/create/extension_template/activation.json.template +15 -15
- dynatrace_extension/cli/create/extension_template/extension/activationSchema.json.template +118 -118
- dynatrace_extension/cli/create/extension_template/extension/extension.yaml.template +17 -17
- dynatrace_extension/cli/create/extension_template/extension_name/__main__.py.template +43 -43
- dynatrace_extension/cli/create/extension_template/setup.py.template +28 -28
- dynatrace_extension/cli/main.py +428 -428
- dynatrace_extension/cli/schema.py +129 -129
- dynatrace_extension/sdk/__init__.py +3 -3
- dynatrace_extension/sdk/activation.py +43 -43
- dynatrace_extension/sdk/callback.py +134 -134
- dynatrace_extension/sdk/communication.py +482 -482
- dynatrace_extension/sdk/event.py +19 -19
- dynatrace_extension/sdk/extension.py +1045 -1045
- dynatrace_extension/sdk/helper.py +191 -191
- dynatrace_extension/sdk/metric.py +118 -118
- dynatrace_extension/sdk/runtime.py +67 -67
- dynatrace_extension/sdk/vendor/mureq/LICENSE +13 -13
- dynatrace_extension/sdk/vendor/mureq/mureq.py +447 -447
- dt_extensions_sdk-1.1.22.dist-info/RECORD +0 -33
- {dt_extensions_sdk-1.1.22.dist-info → dt_extensions_sdk-1.1.24.dist-info}/entry_points.txt +0 -0
@@ -1,447 +1,447 @@
|
|
1
|
-
"""
|
2
|
-
mureq is a replacement for python-requests, intended to be vendored
|
3
|
-
in-tree by Linux systems software and other lightweight applications.
|
4
|
-
mureq is copyright 2021 by its contributors and is released under the
|
5
|
-
0BSD ("zero-clause BSD") license.
|
6
|
-
"""
|
7
|
-
import contextlib
|
8
|
-
import io
|
9
|
-
import os.path
|
10
|
-
import socket
|
11
|
-
import ssl
|
12
|
-
import sys
|
13
|
-
import urllib.parse
|
14
|
-
from http.client import HTTPConnection, HTTPException, HTTPMessage, HTTPSConnection
|
15
|
-
|
16
|
-
__version__ = "0.2.0"
|
17
|
-
|
18
|
-
__all__ = [
|
19
|
-
"HTTPException",
|
20
|
-
"TooManyRedirects",
|
21
|
-
"Response",
|
22
|
-
"yield_response",
|
23
|
-
"request",
|
24
|
-
"get",
|
25
|
-
"post",
|
26
|
-
"head",
|
27
|
-
"put",
|
28
|
-
"patch",
|
29
|
-
"delete",
|
30
|
-
]
|
31
|
-
|
32
|
-
DEFAULT_TIMEOUT = 15.0
|
33
|
-
|
34
|
-
# e.g. "Python 3.8.10"
|
35
|
-
DEFAULT_UA = "Python " + sys.version.split()[0]
|
36
|
-
|
37
|
-
|
38
|
-
def request(method, url, *, read_limit=None, **kwargs):
|
39
|
-
"""request performs an HTTP request and reads the entire response body.
|
40
|
-
:param str method: HTTP method to request (e.g. 'GET', 'POST')
|
41
|
-
:param str url: URL to request
|
42
|
-
:param read_limit: maximum number of bytes to read from the body, or None for no limit
|
43
|
-
:type read_limit: int or None
|
44
|
-
:param kwargs: optional arguments defined by yield_response
|
45
|
-
:return: Response object
|
46
|
-
:rtype: Response
|
47
|
-
:raises: HTTPException
|
48
|
-
"""
|
49
|
-
with yield_response(method, url, **kwargs) as response:
|
50
|
-
try:
|
51
|
-
body = response.read(read_limit)
|
52
|
-
except HTTPException:
|
53
|
-
raise
|
54
|
-
except OSError as e:
|
55
|
-
raise HTTPException(str(e)) from e
|
56
|
-
return Response(response.url, response.status, _prepare_incoming_headers(response.headers), body)
|
57
|
-
|
58
|
-
|
59
|
-
def get(url, **kwargs):
|
60
|
-
"""get performs an HTTP GET request."""
|
61
|
-
return request("GET", url=url, **kwargs)
|
62
|
-
|
63
|
-
|
64
|
-
def post(url, body=None, **kwargs):
|
65
|
-
"""post performs an HTTP POST request."""
|
66
|
-
return request("POST", url=url, body=body, **kwargs)
|
67
|
-
|
68
|
-
|
69
|
-
def head(url, **kwargs):
|
70
|
-
"""head performs an HTTP HEAD request."""
|
71
|
-
return request("HEAD", url=url, **kwargs)
|
72
|
-
|
73
|
-
|
74
|
-
def put(url, body=None, **kwargs):
|
75
|
-
"""put performs an HTTP PUT request."""
|
76
|
-
return request("PUT", url=url, body=body, **kwargs)
|
77
|
-
|
78
|
-
|
79
|
-
def patch(url, body=None, **kwargs):
|
80
|
-
"""patch performs an HTTP PATCH request."""
|
81
|
-
return request("PATCH", url=url, body=body, **kwargs)
|
82
|
-
|
83
|
-
|
84
|
-
def delete(url, **kwargs):
|
85
|
-
"""delete performs an HTTP DELETE request."""
|
86
|
-
return request("DELETE", url=url, **kwargs)
|
87
|
-
|
88
|
-
|
89
|
-
@contextlib.contextmanager
|
90
|
-
def yield_response(
|
91
|
-
method,
|
92
|
-
url,
|
93
|
-
*,
|
94
|
-
unix_socket=None,
|
95
|
-
timeout=DEFAULT_TIMEOUT,
|
96
|
-
headers=None,
|
97
|
-
params=None,
|
98
|
-
body=None,
|
99
|
-
form=None,
|
100
|
-
json=None,
|
101
|
-
verify=True,
|
102
|
-
source_address=None,
|
103
|
-
max_redirects=None,
|
104
|
-
ssl_context=None,
|
105
|
-
):
|
106
|
-
"""yield_response is a low-level API that exposes the actual
|
107
|
-
http.client.HTTPResponse via a contextmanager.
|
108
|
-
Note that unlike mureq.Response, http.client.HTTPResponse does not
|
109
|
-
automatically canonicalize multiple appearances of the same header by
|
110
|
-
joining them together with a comma delimiter. To retrieve canonicalized
|
111
|
-
headers from the response, use response.getheader():
|
112
|
-
https://docs.python.org/3/library/http.client.html#http.client.HTTPResponse.getheader
|
113
|
-
:param str method: HTTP method to request (e.g. 'GET', 'POST')
|
114
|
-
:param str url: URL to request
|
115
|
-
:param unix_socket: path to Unix domain socket to query, or None for a normal TCP request
|
116
|
-
:type unix_socket: str or None
|
117
|
-
:param timeout: timeout in seconds, or None for no timeout (default: 15 seconds)
|
118
|
-
:type timeout: float or None
|
119
|
-
:param headers: HTTP headers as a mapping or list of key-value pairs
|
120
|
-
:param params: parameters to be URL-encoded and added to the query string, as a mapping or list of key-value pairs
|
121
|
-
:param body: payload body of the request
|
122
|
-
:type body: bytes or None
|
123
|
-
:param form: parameters to be form-encoded and sent as the payload body, as a mapping or list of key-value pairs
|
124
|
-
:param json: object to be serialized as JSON and sent as the payload body
|
125
|
-
:param bool verify: whether to verify TLS certificates (default: True)
|
126
|
-
:param source_address: source address to bind to for TCP
|
127
|
-
:type source_address: str or tuple(str, int) or None
|
128
|
-
:param max_redirects: maximum number of redirects to follow, or None (the default) for no redirection
|
129
|
-
:type max_redirects: int or None
|
130
|
-
:param ssl_context: TLS config to control certificate validation, or None for default behavior
|
131
|
-
:type ssl_context: ssl.SSLContext or None
|
132
|
-
:return: http.client.HTTPResponse, yielded as context manager
|
133
|
-
:rtype: http.client.HTTPResponse
|
134
|
-
:raises: HTTPException
|
135
|
-
"""
|
136
|
-
method = method.upper()
|
137
|
-
headers = _prepare_outgoing_headers(headers)
|
138
|
-
enc_params = _prepare_params(params)
|
139
|
-
body = _prepare_body(body, form, json, headers)
|
140
|
-
|
141
|
-
visited_urls = []
|
142
|
-
|
143
|
-
while max_redirects is None or len(visited_urls) <= max_redirects:
|
144
|
-
url, conn, path = _prepare_request(
|
145
|
-
method,
|
146
|
-
url,
|
147
|
-
enc_params=enc_params,
|
148
|
-
timeout=timeout,
|
149
|
-
unix_socket=unix_socket,
|
150
|
-
verify=verify,
|
151
|
-
source_address=source_address,
|
152
|
-
ssl_context=ssl_context,
|
153
|
-
)
|
154
|
-
enc_params = "" # don't reappend enc_params if we get redirected
|
155
|
-
visited_urls.append(url)
|
156
|
-
try:
|
157
|
-
try:
|
158
|
-
conn.request(method, path, headers=headers, body=body)
|
159
|
-
response = conn.getresponse()
|
160
|
-
except HTTPException:
|
161
|
-
raise
|
162
|
-
except OSError as e:
|
163
|
-
# wrap any IOError that is not already an HTTPException
|
164
|
-
# in HTTPException, exposing a uniform API for remote errors
|
165
|
-
raise HTTPException(str(e)) from e
|
166
|
-
redirect_url = _check_redirect(url, response.status, response.headers)
|
167
|
-
if max_redirects is None or redirect_url is None:
|
168
|
-
response.url = url # https://bugs.python.org/issue42062
|
169
|
-
yield response
|
170
|
-
return
|
171
|
-
else:
|
172
|
-
url = redirect_url
|
173
|
-
if response.status == 303:
|
174
|
-
# 303 See Other: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303
|
175
|
-
method = "GET"
|
176
|
-
finally:
|
177
|
-
conn.close()
|
178
|
-
|
179
|
-
raise TooManyRedirects(visited_urls)
|
180
|
-
|
181
|
-
|
182
|
-
class Response:
|
183
|
-
"""Response contains a completely consumed HTTP response.
|
184
|
-
:ivar str url: the retrieved URL, indicating whether a redirection occurred
|
185
|
-
:ivar int status_code: the HTTP status code
|
186
|
-
:ivar http.client.HTTPMessage headers: the HTTP headers
|
187
|
-
:ivar bytes body: the payload body of the response
|
188
|
-
"""
|
189
|
-
|
190
|
-
__slots__ = ("url", "status_code", "headers", "body")
|
191
|
-
|
192
|
-
def __init__(self, url, status_code, headers, body):
|
193
|
-
self.url, self.status_code, self.headers, self.body = url, status_code, headers, body
|
194
|
-
|
195
|
-
def __repr__(self):
|
196
|
-
return f"Response(status_code={self.status_code:d})"
|
197
|
-
|
198
|
-
@property
|
199
|
-
def ok(self):
|
200
|
-
"""ok returns whether the response had a successful status code
|
201
|
-
(anything other than a 40x or 50x)."""
|
202
|
-
return not (400 <= self.status_code < 600)
|
203
|
-
|
204
|
-
@property
|
205
|
-
def content(self):
|
206
|
-
"""content returns the response body (the `body` member). This is an
|
207
|
-
alias for compatibility with requests.Response."""
|
208
|
-
return self.body
|
209
|
-
|
210
|
-
def raise_for_status(self):
|
211
|
-
"""raise_for_status checks the response's success code, raising an
|
212
|
-
exception for error codes."""
|
213
|
-
if not self.ok:
|
214
|
-
raise HTTPErrorStatus(self.status_code)
|
215
|
-
|
216
|
-
def json(self):
|
217
|
-
"""Attempts to deserialize the response body as UTF-8 encoded JSON."""
|
218
|
-
import json as jsonlib
|
219
|
-
|
220
|
-
return jsonlib.loads(self.body)
|
221
|
-
|
222
|
-
def _debugstr(self):
|
223
|
-
buf = io.StringIO()
|
224
|
-
print("HTTP", self.status_code, file=buf)
|
225
|
-
for k, v in self.headers.items():
|
226
|
-
print(f"{k}: {v}", file=buf)
|
227
|
-
print(file=buf)
|
228
|
-
try:
|
229
|
-
print(self.body.decode("utf-8"), file=buf)
|
230
|
-
except UnicodeDecodeError:
|
231
|
-
print(f"<{len(self.body)} bytes binary data>", file=buf)
|
232
|
-
return buf.getvalue()
|
233
|
-
|
234
|
-
|
235
|
-
class TooManyRedirects(HTTPException):
|
236
|
-
"""TooManyRedirects is raised when automatic following of redirects was
|
237
|
-
enabled, but the server redirected too many times without completing."""
|
238
|
-
|
239
|
-
pass
|
240
|
-
|
241
|
-
|
242
|
-
class HTTPErrorStatus(HTTPException):
|
243
|
-
"""HTTPErrorStatus is raised by Response.raise_for_status() to indicate an
|
244
|
-
HTTP error code (a 40x or a 50x). Note that a well-formed response with an
|
245
|
-
error code does not result in an exception unless raise_for_status() is
|
246
|
-
called explicitly.
|
247
|
-
"""
|
248
|
-
|
249
|
-
def __init__(self, status_code):
|
250
|
-
self.status_code = status_code
|
251
|
-
|
252
|
-
def __str__(self):
|
253
|
-
return f"HTTP response returned error code {self.status_code:d}"
|
254
|
-
|
255
|
-
|
256
|
-
# end public API, begin internal implementation details
|
257
|
-
|
258
|
-
_JSON_CONTENTTYPE = "application/json"
|
259
|
-
_FORM_CONTENTTYPE = "application/x-www-form-urlencoded"
|
260
|
-
|
261
|
-
|
262
|
-
class UnixHTTPConnection(HTTPConnection):
|
263
|
-
"""UnixHTTPConnection is a subclass of HTTPConnection that connects to a
|
264
|
-
Unix domain stream socket instead of a TCP address.
|
265
|
-
"""
|
266
|
-
|
267
|
-
def __init__(self, path, timeout=DEFAULT_TIMEOUT):
|
268
|
-
super(UnixHTTPConnection, self).__init__("localhost", timeout=timeout)
|
269
|
-
self._unix_path = path
|
270
|
-
|
271
|
-
def connect(self):
|
272
|
-
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
273
|
-
try:
|
274
|
-
sock.settimeout(self.timeout)
|
275
|
-
sock.connect(self._unix_path)
|
276
|
-
except Exception:
|
277
|
-
sock.close()
|
278
|
-
raise
|
279
|
-
self.sock = sock
|
280
|
-
|
281
|
-
|
282
|
-
def _check_redirect(url, status, response_headers):
|
283
|
-
"""Return the URL to redirect to, or None for no redirection."""
|
284
|
-
if status not in (301, 302, 303, 307, 308):
|
285
|
-
return None
|
286
|
-
location = response_headers.get("Location")
|
287
|
-
if not location:
|
288
|
-
return None
|
289
|
-
parsed_location = urllib.parse.urlparse(location)
|
290
|
-
if parsed_location.scheme:
|
291
|
-
# absolute URL
|
292
|
-
return location
|
293
|
-
|
294
|
-
old_url = urllib.parse.urlparse(url)
|
295
|
-
if location.startswith("/"):
|
296
|
-
# absolute path on old hostname
|
297
|
-
return urllib.parse.urlunparse(
|
298
|
-
(
|
299
|
-
old_url.scheme,
|
300
|
-
old_url.netloc,
|
301
|
-
parsed_location.path,
|
302
|
-
parsed_location.params,
|
303
|
-
parsed_location.query,
|
304
|
-
parsed_location.fragment,
|
305
|
-
)
|
306
|
-
)
|
307
|
-
|
308
|
-
# relative path on old hostname
|
309
|
-
old_dir, _old_file = os.path.split(old_url.path)
|
310
|
-
new_path = os.path.join(old_dir, location)
|
311
|
-
return urllib.parse.urlunparse(
|
312
|
-
(
|
313
|
-
old_url.scheme,
|
314
|
-
old_url.netloc,
|
315
|
-
new_path,
|
316
|
-
parsed_location.params,
|
317
|
-
parsed_location.query,
|
318
|
-
parsed_location.fragment,
|
319
|
-
)
|
320
|
-
)
|
321
|
-
|
322
|
-
|
323
|
-
def _prepare_outgoing_headers(headers):
|
324
|
-
if headers is None:
|
325
|
-
headers = HTTPMessage()
|
326
|
-
elif not isinstance(headers, HTTPMessage):
|
327
|
-
new_headers = HTTPMessage()
|
328
|
-
if hasattr(headers, "items"):
|
329
|
-
iterator = headers.items()
|
330
|
-
else:
|
331
|
-
iterator = iter(headers)
|
332
|
-
for k, v in iterator:
|
333
|
-
new_headers[k] = v
|
334
|
-
headers = new_headers
|
335
|
-
_setdefault_header(headers, "User-Agent", DEFAULT_UA)
|
336
|
-
return headers
|
337
|
-
|
338
|
-
|
339
|
-
# XXX join multi-headers together so that get(), __getitem__(),
|
340
|
-
# etc. behave intuitively, then stuff them back in an HTTPMessage.
|
341
|
-
def _prepare_incoming_headers(headers):
|
342
|
-
headers_dict = {}
|
343
|
-
for k, v in headers.items():
|
344
|
-
headers_dict.setdefault(k, []).append(v)
|
345
|
-
result = HTTPMessage()
|
346
|
-
# note that iterating over headers_dict preserves the original
|
347
|
-
# insertion order in all versions since Python 3.6:
|
348
|
-
for k, vlist in headers_dict.items():
|
349
|
-
result[k] = ",".join(vlist)
|
350
|
-
return result
|
351
|
-
|
352
|
-
|
353
|
-
def _setdefault_header(headers, name, value):
|
354
|
-
if name not in headers:
|
355
|
-
headers[name] = value
|
356
|
-
|
357
|
-
|
358
|
-
def _prepare_body(body, form, json, headers):
|
359
|
-
if body is not None:
|
360
|
-
if not isinstance(body, bytes):
|
361
|
-
raise TypeError("body must be bytes or None", type(body))
|
362
|
-
return body
|
363
|
-
|
364
|
-
if json is not None:
|
365
|
-
_setdefault_header(headers, "Content-Type", _JSON_CONTENTTYPE)
|
366
|
-
import json as jsonlib
|
367
|
-
|
368
|
-
return jsonlib.dumps(json).encode("utf-8")
|
369
|
-
|
370
|
-
if form is not None:
|
371
|
-
_setdefault_header(headers, "Content-Type", _FORM_CONTENTTYPE)
|
372
|
-
return urllib.parse.urlencode(form, doseq=True)
|
373
|
-
|
374
|
-
return None
|
375
|
-
|
376
|
-
|
377
|
-
def _prepare_params(params):
|
378
|
-
if params is None:
|
379
|
-
return ""
|
380
|
-
return urllib.parse.urlencode(params, doseq=True)
|
381
|
-
|
382
|
-
|
383
|
-
def _prepare_request(
|
384
|
-
method,
|
385
|
-
url,
|
386
|
-
*,
|
387
|
-
enc_params="",
|
388
|
-
timeout=DEFAULT_TIMEOUT,
|
389
|
-
source_address=None,
|
390
|
-
unix_socket=None,
|
391
|
-
verify=True,
|
392
|
-
ssl_context=None,
|
393
|
-
):
|
394
|
-
"""Parses the URL, returns the path and the right HTTPConnection subclass."""
|
395
|
-
parsed_url = urllib.parse.urlparse(url)
|
396
|
-
|
397
|
-
is_unix = unix_socket is not None
|
398
|
-
scheme = parsed_url.scheme.lower()
|
399
|
-
if scheme.endswith("+unix"):
|
400
|
-
scheme = scheme[:-5]
|
401
|
-
is_unix = True
|
402
|
-
if scheme == "https":
|
403
|
-
raise ValueError("https+unix is not implemented")
|
404
|
-
|
405
|
-
if scheme not in ("http", "https"):
|
406
|
-
raise ValueError("unrecognized scheme", scheme)
|
407
|
-
|
408
|
-
is_https = scheme == "https"
|
409
|
-
host = parsed_url.hostname
|
410
|
-
port = 443 if is_https else 80
|
411
|
-
if parsed_url.port:
|
412
|
-
port = parsed_url.port
|
413
|
-
|
414
|
-
if is_unix and unix_socket is None:
|
415
|
-
unix_socket = urllib.parse.unquote(parsed_url.netloc)
|
416
|
-
|
417
|
-
path = parsed_url.path
|
418
|
-
if parsed_url.query:
|
419
|
-
if enc_params:
|
420
|
-
path = f"{path}?{parsed_url.query}&{enc_params}"
|
421
|
-
else:
|
422
|
-
path = f"{path}?{parsed_url.query}"
|
423
|
-
else:
|
424
|
-
if enc_params:
|
425
|
-
path = f"{path}?{enc_params}"
|
426
|
-
else:
|
427
|
-
pass # just parsed_url.path in this case
|
428
|
-
|
429
|
-
if isinstance(source_address, str):
|
430
|
-
source_address = (source_address, 0)
|
431
|
-
|
432
|
-
if is_unix:
|
433
|
-
conn = UnixHTTPConnection(unix_socket, timeout=timeout)
|
434
|
-
elif is_https:
|
435
|
-
if ssl_context is None:
|
436
|
-
ssl_context = ssl.create_default_context()
|
437
|
-
if not verify:
|
438
|
-
ssl_context.check_hostname = False
|
439
|
-
ssl_context.verify_mode = ssl.CERT_NONE
|
440
|
-
conn = HTTPSConnection(host, port, source_address=source_address, timeout=timeout, context=ssl_context)
|
441
|
-
else:
|
442
|
-
conn = HTTPConnection(host, port, source_address=source_address, timeout=timeout)
|
443
|
-
|
444
|
-
munged_url = urllib.parse.urlunparse(
|
445
|
-
(parsed_url.scheme, parsed_url.netloc, path, parsed_url.params, "", parsed_url.fragment)
|
446
|
-
)
|
447
|
-
return munged_url, conn, path
|
1
|
+
"""
|
2
|
+
mureq is a replacement for python-requests, intended to be vendored
|
3
|
+
in-tree by Linux systems software and other lightweight applications.
|
4
|
+
mureq is copyright 2021 by its contributors and is released under the
|
5
|
+
0BSD ("zero-clause BSD") license.
|
6
|
+
"""
|
7
|
+
import contextlib
|
8
|
+
import io
|
9
|
+
import os.path
|
10
|
+
import socket
|
11
|
+
import ssl
|
12
|
+
import sys
|
13
|
+
import urllib.parse
|
14
|
+
from http.client import HTTPConnection, HTTPException, HTTPMessage, HTTPSConnection
|
15
|
+
|
16
|
+
__version__ = "0.2.0"
|
17
|
+
|
18
|
+
__all__ = [
|
19
|
+
"HTTPException",
|
20
|
+
"TooManyRedirects",
|
21
|
+
"Response",
|
22
|
+
"yield_response",
|
23
|
+
"request",
|
24
|
+
"get",
|
25
|
+
"post",
|
26
|
+
"head",
|
27
|
+
"put",
|
28
|
+
"patch",
|
29
|
+
"delete",
|
30
|
+
]
|
31
|
+
|
32
|
+
DEFAULT_TIMEOUT = 15.0
|
33
|
+
|
34
|
+
# e.g. "Python 3.8.10"
|
35
|
+
DEFAULT_UA = "Python " + sys.version.split()[0]
|
36
|
+
|
37
|
+
|
38
|
+
def request(method, url, *, read_limit=None, **kwargs):
|
39
|
+
"""request performs an HTTP request and reads the entire response body.
|
40
|
+
:param str method: HTTP method to request (e.g. 'GET', 'POST')
|
41
|
+
:param str url: URL to request
|
42
|
+
:param read_limit: maximum number of bytes to read from the body, or None for no limit
|
43
|
+
:type read_limit: int or None
|
44
|
+
:param kwargs: optional arguments defined by yield_response
|
45
|
+
:return: Response object
|
46
|
+
:rtype: Response
|
47
|
+
:raises: HTTPException
|
48
|
+
"""
|
49
|
+
with yield_response(method, url, **kwargs) as response:
|
50
|
+
try:
|
51
|
+
body = response.read(read_limit)
|
52
|
+
except HTTPException:
|
53
|
+
raise
|
54
|
+
except OSError as e:
|
55
|
+
raise HTTPException(str(e)) from e
|
56
|
+
return Response(response.url, response.status, _prepare_incoming_headers(response.headers), body)
|
57
|
+
|
58
|
+
|
59
|
+
def get(url, **kwargs):
|
60
|
+
"""get performs an HTTP GET request."""
|
61
|
+
return request("GET", url=url, **kwargs)
|
62
|
+
|
63
|
+
|
64
|
+
def post(url, body=None, **kwargs):
|
65
|
+
"""post performs an HTTP POST request."""
|
66
|
+
return request("POST", url=url, body=body, **kwargs)
|
67
|
+
|
68
|
+
|
69
|
+
def head(url, **kwargs):
|
70
|
+
"""head performs an HTTP HEAD request."""
|
71
|
+
return request("HEAD", url=url, **kwargs)
|
72
|
+
|
73
|
+
|
74
|
+
def put(url, body=None, **kwargs):
|
75
|
+
"""put performs an HTTP PUT request."""
|
76
|
+
return request("PUT", url=url, body=body, **kwargs)
|
77
|
+
|
78
|
+
|
79
|
+
def patch(url, body=None, **kwargs):
|
80
|
+
"""patch performs an HTTP PATCH request."""
|
81
|
+
return request("PATCH", url=url, body=body, **kwargs)
|
82
|
+
|
83
|
+
|
84
|
+
def delete(url, **kwargs):
|
85
|
+
"""delete performs an HTTP DELETE request."""
|
86
|
+
return request("DELETE", url=url, **kwargs)
|
87
|
+
|
88
|
+
|
89
|
+
@contextlib.contextmanager
|
90
|
+
def yield_response(
|
91
|
+
method,
|
92
|
+
url,
|
93
|
+
*,
|
94
|
+
unix_socket=None,
|
95
|
+
timeout=DEFAULT_TIMEOUT,
|
96
|
+
headers=None,
|
97
|
+
params=None,
|
98
|
+
body=None,
|
99
|
+
form=None,
|
100
|
+
json=None,
|
101
|
+
verify=True,
|
102
|
+
source_address=None,
|
103
|
+
max_redirects=None,
|
104
|
+
ssl_context=None,
|
105
|
+
):
|
106
|
+
"""yield_response is a low-level API that exposes the actual
|
107
|
+
http.client.HTTPResponse via a contextmanager.
|
108
|
+
Note that unlike mureq.Response, http.client.HTTPResponse does not
|
109
|
+
automatically canonicalize multiple appearances of the same header by
|
110
|
+
joining them together with a comma delimiter. To retrieve canonicalized
|
111
|
+
headers from the response, use response.getheader():
|
112
|
+
https://docs.python.org/3/library/http.client.html#http.client.HTTPResponse.getheader
|
113
|
+
:param str method: HTTP method to request (e.g. 'GET', 'POST')
|
114
|
+
:param str url: URL to request
|
115
|
+
:param unix_socket: path to Unix domain socket to query, or None for a normal TCP request
|
116
|
+
:type unix_socket: str or None
|
117
|
+
:param timeout: timeout in seconds, or None for no timeout (default: 15 seconds)
|
118
|
+
:type timeout: float or None
|
119
|
+
:param headers: HTTP headers as a mapping or list of key-value pairs
|
120
|
+
:param params: parameters to be URL-encoded and added to the query string, as a mapping or list of key-value pairs
|
121
|
+
:param body: payload body of the request
|
122
|
+
:type body: bytes or None
|
123
|
+
:param form: parameters to be form-encoded and sent as the payload body, as a mapping or list of key-value pairs
|
124
|
+
:param json: object to be serialized as JSON and sent as the payload body
|
125
|
+
:param bool verify: whether to verify TLS certificates (default: True)
|
126
|
+
:param source_address: source address to bind to for TCP
|
127
|
+
:type source_address: str or tuple(str, int) or None
|
128
|
+
:param max_redirects: maximum number of redirects to follow, or None (the default) for no redirection
|
129
|
+
:type max_redirects: int or None
|
130
|
+
:param ssl_context: TLS config to control certificate validation, or None for default behavior
|
131
|
+
:type ssl_context: ssl.SSLContext or None
|
132
|
+
:return: http.client.HTTPResponse, yielded as context manager
|
133
|
+
:rtype: http.client.HTTPResponse
|
134
|
+
:raises: HTTPException
|
135
|
+
"""
|
136
|
+
method = method.upper()
|
137
|
+
headers = _prepare_outgoing_headers(headers)
|
138
|
+
enc_params = _prepare_params(params)
|
139
|
+
body = _prepare_body(body, form, json, headers)
|
140
|
+
|
141
|
+
visited_urls = []
|
142
|
+
|
143
|
+
while max_redirects is None or len(visited_urls) <= max_redirects:
|
144
|
+
url, conn, path = _prepare_request(
|
145
|
+
method,
|
146
|
+
url,
|
147
|
+
enc_params=enc_params,
|
148
|
+
timeout=timeout,
|
149
|
+
unix_socket=unix_socket,
|
150
|
+
verify=verify,
|
151
|
+
source_address=source_address,
|
152
|
+
ssl_context=ssl_context,
|
153
|
+
)
|
154
|
+
enc_params = "" # don't reappend enc_params if we get redirected
|
155
|
+
visited_urls.append(url)
|
156
|
+
try:
|
157
|
+
try:
|
158
|
+
conn.request(method, path, headers=headers, body=body)
|
159
|
+
response = conn.getresponse()
|
160
|
+
except HTTPException:
|
161
|
+
raise
|
162
|
+
except OSError as e:
|
163
|
+
# wrap any IOError that is not already an HTTPException
|
164
|
+
# in HTTPException, exposing a uniform API for remote errors
|
165
|
+
raise HTTPException(str(e)) from e
|
166
|
+
redirect_url = _check_redirect(url, response.status, response.headers)
|
167
|
+
if max_redirects is None or redirect_url is None:
|
168
|
+
response.url = url # https://bugs.python.org/issue42062
|
169
|
+
yield response
|
170
|
+
return
|
171
|
+
else:
|
172
|
+
url = redirect_url
|
173
|
+
if response.status == 303:
|
174
|
+
# 303 See Other: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303
|
175
|
+
method = "GET"
|
176
|
+
finally:
|
177
|
+
conn.close()
|
178
|
+
|
179
|
+
raise TooManyRedirects(visited_urls)
|
180
|
+
|
181
|
+
|
182
|
+
class Response:
|
183
|
+
"""Response contains a completely consumed HTTP response.
|
184
|
+
:ivar str url: the retrieved URL, indicating whether a redirection occurred
|
185
|
+
:ivar int status_code: the HTTP status code
|
186
|
+
:ivar http.client.HTTPMessage headers: the HTTP headers
|
187
|
+
:ivar bytes body: the payload body of the response
|
188
|
+
"""
|
189
|
+
|
190
|
+
__slots__ = ("url", "status_code", "headers", "body")
|
191
|
+
|
192
|
+
def __init__(self, url, status_code, headers, body):
|
193
|
+
self.url, self.status_code, self.headers, self.body = url, status_code, headers, body
|
194
|
+
|
195
|
+
def __repr__(self):
|
196
|
+
return f"Response(status_code={self.status_code:d})"
|
197
|
+
|
198
|
+
@property
|
199
|
+
def ok(self):
|
200
|
+
"""ok returns whether the response had a successful status code
|
201
|
+
(anything other than a 40x or 50x)."""
|
202
|
+
return not (400 <= self.status_code < 600)
|
203
|
+
|
204
|
+
@property
|
205
|
+
def content(self):
|
206
|
+
"""content returns the response body (the `body` member). This is an
|
207
|
+
alias for compatibility with requests.Response."""
|
208
|
+
return self.body
|
209
|
+
|
210
|
+
def raise_for_status(self):
|
211
|
+
"""raise_for_status checks the response's success code, raising an
|
212
|
+
exception for error codes."""
|
213
|
+
if not self.ok:
|
214
|
+
raise HTTPErrorStatus(self.status_code)
|
215
|
+
|
216
|
+
def json(self):
|
217
|
+
"""Attempts to deserialize the response body as UTF-8 encoded JSON."""
|
218
|
+
import json as jsonlib
|
219
|
+
|
220
|
+
return jsonlib.loads(self.body)
|
221
|
+
|
222
|
+
def _debugstr(self):
|
223
|
+
buf = io.StringIO()
|
224
|
+
print("HTTP", self.status_code, file=buf)
|
225
|
+
for k, v in self.headers.items():
|
226
|
+
print(f"{k}: {v}", file=buf)
|
227
|
+
print(file=buf)
|
228
|
+
try:
|
229
|
+
print(self.body.decode("utf-8"), file=buf)
|
230
|
+
except UnicodeDecodeError:
|
231
|
+
print(f"<{len(self.body)} bytes binary data>", file=buf)
|
232
|
+
return buf.getvalue()
|
233
|
+
|
234
|
+
|
235
|
+
class TooManyRedirects(HTTPException):
|
236
|
+
"""TooManyRedirects is raised when automatic following of redirects was
|
237
|
+
enabled, but the server redirected too many times without completing."""
|
238
|
+
|
239
|
+
pass
|
240
|
+
|
241
|
+
|
242
|
+
class HTTPErrorStatus(HTTPException):
|
243
|
+
"""HTTPErrorStatus is raised by Response.raise_for_status() to indicate an
|
244
|
+
HTTP error code (a 40x or a 50x). Note that a well-formed response with an
|
245
|
+
error code does not result in an exception unless raise_for_status() is
|
246
|
+
called explicitly.
|
247
|
+
"""
|
248
|
+
|
249
|
+
def __init__(self, status_code):
|
250
|
+
self.status_code = status_code
|
251
|
+
|
252
|
+
def __str__(self):
|
253
|
+
return f"HTTP response returned error code {self.status_code:d}"
|
254
|
+
|
255
|
+
|
256
|
+
# end public API, begin internal implementation details
|
257
|
+
|
258
|
+
_JSON_CONTENTTYPE = "application/json"
|
259
|
+
_FORM_CONTENTTYPE = "application/x-www-form-urlencoded"
|
260
|
+
|
261
|
+
|
262
|
+
class UnixHTTPConnection(HTTPConnection):
|
263
|
+
"""UnixHTTPConnection is a subclass of HTTPConnection that connects to a
|
264
|
+
Unix domain stream socket instead of a TCP address.
|
265
|
+
"""
|
266
|
+
|
267
|
+
def __init__(self, path, timeout=DEFAULT_TIMEOUT):
|
268
|
+
super(UnixHTTPConnection, self).__init__("localhost", timeout=timeout)
|
269
|
+
self._unix_path = path
|
270
|
+
|
271
|
+
def connect(self):
|
272
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
273
|
+
try:
|
274
|
+
sock.settimeout(self.timeout)
|
275
|
+
sock.connect(self._unix_path)
|
276
|
+
except Exception:
|
277
|
+
sock.close()
|
278
|
+
raise
|
279
|
+
self.sock = sock
|
280
|
+
|
281
|
+
|
282
|
+
def _check_redirect(url, status, response_headers):
|
283
|
+
"""Return the URL to redirect to, or None for no redirection."""
|
284
|
+
if status not in (301, 302, 303, 307, 308):
|
285
|
+
return None
|
286
|
+
location = response_headers.get("Location")
|
287
|
+
if not location:
|
288
|
+
return None
|
289
|
+
parsed_location = urllib.parse.urlparse(location)
|
290
|
+
if parsed_location.scheme:
|
291
|
+
# absolute URL
|
292
|
+
return location
|
293
|
+
|
294
|
+
old_url = urllib.parse.urlparse(url)
|
295
|
+
if location.startswith("/"):
|
296
|
+
# absolute path on old hostname
|
297
|
+
return urllib.parse.urlunparse(
|
298
|
+
(
|
299
|
+
old_url.scheme,
|
300
|
+
old_url.netloc,
|
301
|
+
parsed_location.path,
|
302
|
+
parsed_location.params,
|
303
|
+
parsed_location.query,
|
304
|
+
parsed_location.fragment,
|
305
|
+
)
|
306
|
+
)
|
307
|
+
|
308
|
+
# relative path on old hostname
|
309
|
+
old_dir, _old_file = os.path.split(old_url.path)
|
310
|
+
new_path = os.path.join(old_dir, location)
|
311
|
+
return urllib.parse.urlunparse(
|
312
|
+
(
|
313
|
+
old_url.scheme,
|
314
|
+
old_url.netloc,
|
315
|
+
new_path,
|
316
|
+
parsed_location.params,
|
317
|
+
parsed_location.query,
|
318
|
+
parsed_location.fragment,
|
319
|
+
)
|
320
|
+
)
|
321
|
+
|
322
|
+
|
323
|
+
def _prepare_outgoing_headers(headers):
|
324
|
+
if headers is None:
|
325
|
+
headers = HTTPMessage()
|
326
|
+
elif not isinstance(headers, HTTPMessage):
|
327
|
+
new_headers = HTTPMessage()
|
328
|
+
if hasattr(headers, "items"):
|
329
|
+
iterator = headers.items()
|
330
|
+
else:
|
331
|
+
iterator = iter(headers)
|
332
|
+
for k, v in iterator:
|
333
|
+
new_headers[k] = v
|
334
|
+
headers = new_headers
|
335
|
+
_setdefault_header(headers, "User-Agent", DEFAULT_UA)
|
336
|
+
return headers
|
337
|
+
|
338
|
+
|
339
|
+
# XXX join multi-headers together so that get(), __getitem__(),
|
340
|
+
# etc. behave intuitively, then stuff them back in an HTTPMessage.
|
341
|
+
def _prepare_incoming_headers(headers):
|
342
|
+
headers_dict = {}
|
343
|
+
for k, v in headers.items():
|
344
|
+
headers_dict.setdefault(k, []).append(v)
|
345
|
+
result = HTTPMessage()
|
346
|
+
# note that iterating over headers_dict preserves the original
|
347
|
+
# insertion order in all versions since Python 3.6:
|
348
|
+
for k, vlist in headers_dict.items():
|
349
|
+
result[k] = ",".join(vlist)
|
350
|
+
return result
|
351
|
+
|
352
|
+
|
353
|
+
def _setdefault_header(headers, name, value):
|
354
|
+
if name not in headers:
|
355
|
+
headers[name] = value
|
356
|
+
|
357
|
+
|
358
|
+
def _prepare_body(body, form, json, headers):
|
359
|
+
if body is not None:
|
360
|
+
if not isinstance(body, bytes):
|
361
|
+
raise TypeError("body must be bytes or None", type(body))
|
362
|
+
return body
|
363
|
+
|
364
|
+
if json is not None:
|
365
|
+
_setdefault_header(headers, "Content-Type", _JSON_CONTENTTYPE)
|
366
|
+
import json as jsonlib
|
367
|
+
|
368
|
+
return jsonlib.dumps(json).encode("utf-8")
|
369
|
+
|
370
|
+
if form is not None:
|
371
|
+
_setdefault_header(headers, "Content-Type", _FORM_CONTENTTYPE)
|
372
|
+
return urllib.parse.urlencode(form, doseq=True)
|
373
|
+
|
374
|
+
return None
|
375
|
+
|
376
|
+
|
377
|
+
def _prepare_params(params):
|
378
|
+
if params is None:
|
379
|
+
return ""
|
380
|
+
return urllib.parse.urlencode(params, doseq=True)
|
381
|
+
|
382
|
+
|
383
|
+
def _prepare_request(
|
384
|
+
method,
|
385
|
+
url,
|
386
|
+
*,
|
387
|
+
enc_params="",
|
388
|
+
timeout=DEFAULT_TIMEOUT,
|
389
|
+
source_address=None,
|
390
|
+
unix_socket=None,
|
391
|
+
verify=True,
|
392
|
+
ssl_context=None,
|
393
|
+
):
|
394
|
+
"""Parses the URL, returns the path and the right HTTPConnection subclass."""
|
395
|
+
parsed_url = urllib.parse.urlparse(url)
|
396
|
+
|
397
|
+
is_unix = unix_socket is not None
|
398
|
+
scheme = parsed_url.scheme.lower()
|
399
|
+
if scheme.endswith("+unix"):
|
400
|
+
scheme = scheme[:-5]
|
401
|
+
is_unix = True
|
402
|
+
if scheme == "https":
|
403
|
+
raise ValueError("https+unix is not implemented")
|
404
|
+
|
405
|
+
if scheme not in ("http", "https"):
|
406
|
+
raise ValueError("unrecognized scheme", scheme)
|
407
|
+
|
408
|
+
is_https = scheme == "https"
|
409
|
+
host = parsed_url.hostname
|
410
|
+
port = 443 if is_https else 80
|
411
|
+
if parsed_url.port:
|
412
|
+
port = parsed_url.port
|
413
|
+
|
414
|
+
if is_unix and unix_socket is None:
|
415
|
+
unix_socket = urllib.parse.unquote(parsed_url.netloc)
|
416
|
+
|
417
|
+
path = parsed_url.path
|
418
|
+
if parsed_url.query:
|
419
|
+
if enc_params:
|
420
|
+
path = f"{path}?{parsed_url.query}&{enc_params}"
|
421
|
+
else:
|
422
|
+
path = f"{path}?{parsed_url.query}"
|
423
|
+
else:
|
424
|
+
if enc_params:
|
425
|
+
path = f"{path}?{enc_params}"
|
426
|
+
else:
|
427
|
+
pass # just parsed_url.path in this case
|
428
|
+
|
429
|
+
if isinstance(source_address, str):
|
430
|
+
source_address = (source_address, 0)
|
431
|
+
|
432
|
+
if is_unix:
|
433
|
+
conn = UnixHTTPConnection(unix_socket, timeout=timeout)
|
434
|
+
elif is_https:
|
435
|
+
if ssl_context is None:
|
436
|
+
ssl_context = ssl.create_default_context()
|
437
|
+
if not verify:
|
438
|
+
ssl_context.check_hostname = False
|
439
|
+
ssl_context.verify_mode = ssl.CERT_NONE
|
440
|
+
conn = HTTPSConnection(host, port, source_address=source_address, timeout=timeout, context=ssl_context)
|
441
|
+
else:
|
442
|
+
conn = HTTPConnection(host, port, source_address=source_address, timeout=timeout)
|
443
|
+
|
444
|
+
munged_url = urllib.parse.urlunparse(
|
445
|
+
(parsed_url.scheme, parsed_url.netloc, path, parsed_url.params, "", parsed_url.fragment)
|
446
|
+
)
|
447
|
+
return munged_url, conn, path
|