rsconnect-python 1.30.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.
- rsconnect/__init__.py +13 -0
- rsconnect/actions.py +565 -0
- rsconnect/actions_content.py +508 -0
- rsconnect/actions_environment.py +160 -0
- rsconnect/actions_integration.py +118 -0
- rsconnect/api.py +2582 -0
- rsconnect/bundle.py +2481 -0
- rsconnect/certificates.py +39 -0
- rsconnect/environment.py +390 -0
- rsconnect/environment_node.py +115 -0
- rsconnect/environment_r.py +300 -0
- rsconnect/exception.py +15 -0
- rsconnect/git_metadata.py +180 -0
- rsconnect/http_support.py +595 -0
- rsconnect/json_web_token.py +178 -0
- rsconnect/log.py +253 -0
- rsconnect/main.py +5889 -0
- rsconnect/metadata.py +879 -0
- rsconnect/models.py +835 -0
- rsconnect/oauth.py +623 -0
- rsconnect/py.typed +0 -0
- rsconnect/pyproject.py +283 -0
- rsconnect/quickstart/__init__.py +16 -0
- rsconnect/quickstart/quickstart.py +486 -0
- rsconnect/quickstart/templates/__init__.py +16 -0
- rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
- rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
- rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
- rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
- rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
- rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
- rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
- rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
- rsconnect/shiny_express.py +136 -0
- rsconnect/snowflake.py +93 -0
- rsconnect/subprocesses/__init__.py +0 -0
- rsconnect/subprocesses/inspect_environment.py +362 -0
- rsconnect/timeouts.py +89 -0
- rsconnect/utils_package.py +261 -0
- rsconnect/validation.py +156 -0
- rsconnect/version_check.py +154 -0
- rsconnect_python-1.30.0.dist-info/METADATA +89 -0
- rsconnect_python-1.30.0.dist-info/RECORD +63 -0
- rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
- rsconnect_python-1.30.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP support wrappers and utility functions
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import ssl
|
|
12
|
+
from http import client as http
|
|
13
|
+
from http.cookies import SimpleCookie
|
|
14
|
+
from typing import IO, Any, Dict, List, Mapping, Optional, Tuple, Union, cast
|
|
15
|
+
from urllib.parse import urlencode, urljoin, urlparse
|
|
16
|
+
from warnings import warn
|
|
17
|
+
|
|
18
|
+
from . import VERSION
|
|
19
|
+
from .log import logger
|
|
20
|
+
from .timeouts import get_request_timeout
|
|
21
|
+
|
|
22
|
+
# A union type that describes types that can be converted to and from JSON.
|
|
23
|
+
JsonData = Union[
|
|
24
|
+
str,
|
|
25
|
+
int,
|
|
26
|
+
float,
|
|
27
|
+
bool,
|
|
28
|
+
None,
|
|
29
|
+
List["JsonData"],
|
|
30
|
+
Tuple["JsonData"],
|
|
31
|
+
Dict[str, "JsonData"],
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_user_agent = f"RSConnectPython/{VERSION}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# noinspection PyUnusedLocal,PyUnresolvedReferences
|
|
38
|
+
def _create_plain_connection(
|
|
39
|
+
host_name: str,
|
|
40
|
+
port: Optional[int],
|
|
41
|
+
disable_tls_check: bool,
|
|
42
|
+
ca_data: Optional[str | bytes],
|
|
43
|
+
):
|
|
44
|
+
"""
|
|
45
|
+
This function is used to create a plain HTTP connection. Note that the 3rd and 4th
|
|
46
|
+
parameters are ignored; they are present to make the signature match the companion
|
|
47
|
+
function for creating SSL connections.
|
|
48
|
+
|
|
49
|
+
:param host_name: the name of the host to connect to.
|
|
50
|
+
:param port: the port to connect to.
|
|
51
|
+
:param disable_tls_check: notes whether TLS verification should be disabled (ignored).
|
|
52
|
+
:param ca_data: any certificate authority information to use (ignored).
|
|
53
|
+
:return: a plain HTTP connection.
|
|
54
|
+
"""
|
|
55
|
+
timeout = get_request_timeout()
|
|
56
|
+
logger.debug(f"The HTTPConnection timeout is set to '{timeout}' seconds")
|
|
57
|
+
return http.HTTPConnection(host_name, port=(port or http.HTTP_PORT), timeout=timeout)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_proxy():
|
|
61
|
+
proxyURL = os.getenv("https_proxy", os.getenv("HTTPS_PROXY"))
|
|
62
|
+
if not proxyURL:
|
|
63
|
+
return None, None, None, None
|
|
64
|
+
parsed = urlparse(proxyURL)
|
|
65
|
+
if parsed.scheme not in ["https"]:
|
|
66
|
+
warn("HTTPS_PROXY scheme is not using https")
|
|
67
|
+
redacted_url = f"{parsed.scheme}://"
|
|
68
|
+
if parsed.username:
|
|
69
|
+
redacted_url += f"{parsed.username}:REDACTED@"
|
|
70
|
+
redacted_url += f"{parsed.hostname}:{parsed.port or 8080}"
|
|
71
|
+
logger.info(f"Using custom proxy server {redacted_url}")
|
|
72
|
+
return parsed.username, parsed.password, parsed.hostname, parsed.port or 8080
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _get_proxy_headers(*args: object, **kwargs: object):
|
|
76
|
+
proxyHeaders = None
|
|
77
|
+
proxyUsername, proxyPassword, _, _ = _get_proxy()
|
|
78
|
+
if proxyUsername and proxyPassword:
|
|
79
|
+
credentials = f"{proxyUsername}:{proxyPassword}"
|
|
80
|
+
credentials = base64.b64encode(credentials.encode("utf-8")).decode("utf-8")
|
|
81
|
+
proxyHeaders = {"Proxy-Authorization": f"Basic {credentials}"}
|
|
82
|
+
return proxyHeaders
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# noinspection PyUnresolvedReferences
|
|
86
|
+
def _create_ssl_connection(
|
|
87
|
+
host_name: str,
|
|
88
|
+
port: Optional[int],
|
|
89
|
+
disable_tls_check: bool,
|
|
90
|
+
ca_data: Optional[str | bytes],
|
|
91
|
+
):
|
|
92
|
+
"""
|
|
93
|
+
This function is used to create a TLS encrypted HTTP connection (SSL).
|
|
94
|
+
|
|
95
|
+
:param host_name: the name of the host to connect to.
|
|
96
|
+
:param port: the port to connect to.
|
|
97
|
+
:param disable_tls_check: notes whether TLS verification should be disabled.
|
|
98
|
+
:param ca_data: any certificate authority information to use.
|
|
99
|
+
:param timeout: the timeout value to use for socket operations.
|
|
100
|
+
:return: a TLS HTTPS connection.
|
|
101
|
+
"""
|
|
102
|
+
if ca_data is not None and disable_tls_check:
|
|
103
|
+
raise ValueError("Cannot both disable TLS checking and provide a custom certificate")
|
|
104
|
+
|
|
105
|
+
no_proxy = os.environ.get("no_proxy", os.environ.get("NO_PROXY", "#"))
|
|
106
|
+
if any([host_name.endswith(host) for host in no_proxy.split(",")]):
|
|
107
|
+
proxyHost, proxyPort = None, None
|
|
108
|
+
else:
|
|
109
|
+
_, _, proxyHost, proxyPort = _get_proxy()
|
|
110
|
+
headers = _get_proxy_headers()
|
|
111
|
+
timeout = get_request_timeout()
|
|
112
|
+
logger.debug(f"The HTTPSConnection timeout is set to '{timeout}' seconds")
|
|
113
|
+
if ca_data is not None:
|
|
114
|
+
return http.HTTPSConnection(
|
|
115
|
+
host_name,
|
|
116
|
+
port=(port or http.HTTPS_PORT),
|
|
117
|
+
timeout=timeout,
|
|
118
|
+
context=ssl.create_default_context(cadata=ca_data),
|
|
119
|
+
)
|
|
120
|
+
elif disable_tls_check:
|
|
121
|
+
if proxyHost is not None:
|
|
122
|
+
tmp = http.HTTPSConnection(
|
|
123
|
+
proxyHost,
|
|
124
|
+
port=proxyPort,
|
|
125
|
+
timeout=timeout,
|
|
126
|
+
context=ssl._create_unverified_context(),
|
|
127
|
+
)
|
|
128
|
+
tmp.set_tunnel(host_name, (port or http.HTTPS_PORT), headers=headers)
|
|
129
|
+
else:
|
|
130
|
+
tmp = http.HTTPSConnection(
|
|
131
|
+
host_name,
|
|
132
|
+
port=(port or http.HTTPS_PORT),
|
|
133
|
+
timeout=timeout,
|
|
134
|
+
context=ssl._create_unverified_context(),
|
|
135
|
+
)
|
|
136
|
+
return tmp
|
|
137
|
+
else:
|
|
138
|
+
if proxyHost is not None:
|
|
139
|
+
tmp = http.HTTPSConnection(proxyHost, port=proxyPort, timeout=timeout)
|
|
140
|
+
tmp.set_tunnel(host_name, (port or http.HTTPS_PORT), headers=headers)
|
|
141
|
+
else:
|
|
142
|
+
tmp = http.HTTPSConnection(host_name, port=(port or http.HTTPS_PORT), timeout=timeout)
|
|
143
|
+
return tmp
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def append_to_path(uri: str, path: str):
|
|
147
|
+
"""
|
|
148
|
+
This is a helper function for appending a path to a URI (i.e, just the path portion
|
|
149
|
+
of a full URL). The main purpose is to make sure one and only one slash ends up between them.
|
|
150
|
+
|
|
151
|
+
:param uri: the URI to append the path to.
|
|
152
|
+
:param path: the path to append.
|
|
153
|
+
:return: the result of the append.
|
|
154
|
+
"""
|
|
155
|
+
if uri.endswith("/") and path.startswith("/"):
|
|
156
|
+
uri += path[1:]
|
|
157
|
+
elif not (uri.endswith("/") or path.startswith("/")):
|
|
158
|
+
uri = uri + "/" + path
|
|
159
|
+
else:
|
|
160
|
+
uri += path
|
|
161
|
+
return uri
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def create_multipart_form_data(
|
|
165
|
+
fields: Dict[str, Union[str, Tuple[str, bytes, str]]],
|
|
166
|
+
boundary: Optional[str] = None,
|
|
167
|
+
) -> Tuple[bytes, str]:
|
|
168
|
+
"""
|
|
169
|
+
Create multipart/form-data body and content-type header.
|
|
170
|
+
|
|
171
|
+
:param fields: Dictionary of field names to values. Values can be:
|
|
172
|
+
- str: Plain text field value
|
|
173
|
+
- Tuple[str, bytes, str]: (filename, file_content, content_type) for file uploads
|
|
174
|
+
:param boundary: Optional boundary string. If not provided, one will be generated.
|
|
175
|
+
:return: Tuple of (body bytes, content-type header value)
|
|
176
|
+
"""
|
|
177
|
+
import secrets
|
|
178
|
+
|
|
179
|
+
if boundary is None:
|
|
180
|
+
boundary = secrets.token_hex(16)
|
|
181
|
+
|
|
182
|
+
body_parts = []
|
|
183
|
+
|
|
184
|
+
for field_name, field_value in fields.items():
|
|
185
|
+
body_parts.append(f"--{boundary}".encode("utf-8"))
|
|
186
|
+
|
|
187
|
+
if isinstance(field_value, tuple):
|
|
188
|
+
# File field
|
|
189
|
+
filename, file_content, content_type = field_value
|
|
190
|
+
disposition = f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"'
|
|
191
|
+
body_parts.append(disposition.encode("utf-8"))
|
|
192
|
+
body_parts.append(f"Content-Type: {content_type}".encode("utf-8"))
|
|
193
|
+
body_parts.append(b"")
|
|
194
|
+
body_parts.append(file_content)
|
|
195
|
+
else:
|
|
196
|
+
# Plain text field
|
|
197
|
+
disposition = f'Content-Disposition: form-data; name="{field_name}"'
|
|
198
|
+
body_parts.append(disposition.encode("utf-8"))
|
|
199
|
+
body_parts.append(b"")
|
|
200
|
+
body_parts.append(field_value.encode("utf-8"))
|
|
201
|
+
|
|
202
|
+
body_parts.append(f"--{boundary}--".encode("utf-8"))
|
|
203
|
+
body_parts.append(b"")
|
|
204
|
+
|
|
205
|
+
body = b"\r\n".join(body_parts)
|
|
206
|
+
content_type = f"multipart/form-data; boundary={boundary}"
|
|
207
|
+
|
|
208
|
+
return body, content_type
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class HTTPResponse(object):
|
|
212
|
+
"""
|
|
213
|
+
This class represents the result of executing an HTTP request.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def __init__(
|
|
217
|
+
self,
|
|
218
|
+
full_uri: str,
|
|
219
|
+
response: Optional[http.HTTPResponse] = None,
|
|
220
|
+
body: Optional[str | bytes] = None,
|
|
221
|
+
exception: Optional[Exception] = None,
|
|
222
|
+
):
|
|
223
|
+
"""
|
|
224
|
+
This constructs an HTTPResponse object. One and only one of the arguments will
|
|
225
|
+
be None.
|
|
226
|
+
|
|
227
|
+
:param full_uri: the URI being accessed.
|
|
228
|
+
:param response: the response object, if no exception occurred.
|
|
229
|
+
:param body: the body of the response, as a string.
|
|
230
|
+
:param exception: the exception, if one occurred.
|
|
231
|
+
"""
|
|
232
|
+
self._response = response
|
|
233
|
+
self.full_uri = full_uri
|
|
234
|
+
self.exception = exception
|
|
235
|
+
self.content_type: str | None = None
|
|
236
|
+
self.json_data: JsonData = None
|
|
237
|
+
self.response_body = body
|
|
238
|
+
|
|
239
|
+
if response is not None:
|
|
240
|
+
self.status = response.status
|
|
241
|
+
self.reason = response.reason
|
|
242
|
+
self.content_type = response.getheader("Content-Type")
|
|
243
|
+
if (
|
|
244
|
+
self.content_type
|
|
245
|
+
and self.content_type.startswith("application/json")
|
|
246
|
+
and self.response_body is not None
|
|
247
|
+
and len(self.response_body) > 0
|
|
248
|
+
):
|
|
249
|
+
try:
|
|
250
|
+
self.json_data = json.loads(self.response_body)
|
|
251
|
+
# if non-empty response body is described by response headers as JSON but JSON decoding fails
|
|
252
|
+
# return the response body
|
|
253
|
+
except json.decoder.JSONDecodeError:
|
|
254
|
+
self.response_body
|
|
255
|
+
|
|
256
|
+
def getheader(self, name: str) -> Optional[str]:
|
|
257
|
+
"""
|
|
258
|
+
This method retrieves a specific header from the response.
|
|
259
|
+
|
|
260
|
+
:param name: the name of the header to retrieve.
|
|
261
|
+
:return: the value of the header, or None if not present.
|
|
262
|
+
"""
|
|
263
|
+
if self._response is None:
|
|
264
|
+
return None
|
|
265
|
+
return self._response.getheader(name)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class HTTPServer(object):
|
|
269
|
+
"""
|
|
270
|
+
This class provides the means to simply and directly invoke HTTP requests against a
|
|
271
|
+
server.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def __init__(
|
|
275
|
+
self,
|
|
276
|
+
url: str,
|
|
277
|
+
disable_tls_check: bool = False,
|
|
278
|
+
ca_data: Optional[str | bytes] = None,
|
|
279
|
+
cookies: Optional[CookieJar] = None,
|
|
280
|
+
):
|
|
281
|
+
"""
|
|
282
|
+
Constructs an HTTPServer object.
|
|
283
|
+
|
|
284
|
+
:param url: the base URL to interact with. This may be just a scheme and server
|
|
285
|
+
or may also include a root path to which all HTTP calls are relative to.
|
|
286
|
+
:param disable_tls_check: notes whether TLS validation should be enforced. Only
|
|
287
|
+
relevant on HTTPS URLs.
|
|
288
|
+
:param ca_data: any certificate authority data to use in specifying client side
|
|
289
|
+
certificates.
|
|
290
|
+
:param cookies: an optional cookie jar. Must be of type `CookieJar` defined in this
|
|
291
|
+
same file (i.e., not the one Python provides).
|
|
292
|
+
"""
|
|
293
|
+
self._url = urlparse(url)
|
|
294
|
+
|
|
295
|
+
if self._url.scheme not in _connection_factory:
|
|
296
|
+
raise ValueError(f'The "{self._url.scheme}" URL scheme is not supported.')
|
|
297
|
+
|
|
298
|
+
self._disable_tls_check = disable_tls_check
|
|
299
|
+
self._ca_data = ca_data
|
|
300
|
+
self._cookies = cookies if cookies is not None else CookieJar()
|
|
301
|
+
self._headers = {"User-Agent": _user_agent}
|
|
302
|
+
self._conn = None
|
|
303
|
+
self._proxy_headers = _get_proxy_headers()
|
|
304
|
+
|
|
305
|
+
self._inject_cookies()
|
|
306
|
+
|
|
307
|
+
def authorization(self, auth_text: str):
|
|
308
|
+
self._headers["Authorization"] = auth_text
|
|
309
|
+
|
|
310
|
+
def get_authorization(self):
|
|
311
|
+
if "Authorization" not in self._headers:
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
return self._headers["Authorization"]
|
|
315
|
+
|
|
316
|
+
def key_authorization(self, key: str):
|
|
317
|
+
self.authorization(f"Key {key}")
|
|
318
|
+
|
|
319
|
+
def bootstrap_authorization(self, key: str):
|
|
320
|
+
self.authorization(f"Connect-Bootstrap {key}")
|
|
321
|
+
|
|
322
|
+
def snowflake_authorization(self, token: str):
|
|
323
|
+
self.authorization(f'Snowflake Token="{token}"')
|
|
324
|
+
|
|
325
|
+
def _get_full_path(self, path: str):
|
|
326
|
+
return append_to_path(self._url.path, path)
|
|
327
|
+
|
|
328
|
+
def __enter__(self):
|
|
329
|
+
if self._url.hostname is None:
|
|
330
|
+
raise ValueError("The URL does not contain a hostname.")
|
|
331
|
+
|
|
332
|
+
factory = _connection_factory[self._url.scheme]
|
|
333
|
+
self._conn = factory(
|
|
334
|
+
self._url.hostname,
|
|
335
|
+
self._url.port,
|
|
336
|
+
self._disable_tls_check,
|
|
337
|
+
self._ca_data,
|
|
338
|
+
)
|
|
339
|
+
return self
|
|
340
|
+
|
|
341
|
+
def __exit__(self, *args: object):
|
|
342
|
+
if self._conn is not None:
|
|
343
|
+
self._conn.close()
|
|
344
|
+
self._conn = None
|
|
345
|
+
|
|
346
|
+
def get(
|
|
347
|
+
self,
|
|
348
|
+
path: str,
|
|
349
|
+
query_params: Optional[Mapping[str, JsonData]] = None,
|
|
350
|
+
decode_response: bool = True,
|
|
351
|
+
) -> JsonData | HTTPResponse:
|
|
352
|
+
return self.request("GET", path, query_params, decode_response=decode_response)
|
|
353
|
+
|
|
354
|
+
def post(
|
|
355
|
+
self,
|
|
356
|
+
path: str,
|
|
357
|
+
query_params: Optional[Mapping[str, JsonData]] = None,
|
|
358
|
+
body: str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None = None,
|
|
359
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
360
|
+
) -> JsonData | HTTPResponse:
|
|
361
|
+
if headers is None:
|
|
362
|
+
headers = {}
|
|
363
|
+
return self.request("POST", path, query_params, body, headers=headers)
|
|
364
|
+
|
|
365
|
+
def patch(
|
|
366
|
+
self,
|
|
367
|
+
path: str,
|
|
368
|
+
query_params: Optional[Mapping[str, JsonData]] = None,
|
|
369
|
+
body: str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None = None,
|
|
370
|
+
) -> JsonData | HTTPResponse:
|
|
371
|
+
return self.request("PATCH", path, query_params, body)
|
|
372
|
+
|
|
373
|
+
def put(
|
|
374
|
+
self,
|
|
375
|
+
path: str,
|
|
376
|
+
query_params: Optional[Mapping[str, JsonData]] = None,
|
|
377
|
+
body: str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None = None,
|
|
378
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
379
|
+
decode_response: bool = True,
|
|
380
|
+
) -> JsonData | HTTPResponse:
|
|
381
|
+
if headers is None:
|
|
382
|
+
headers = {}
|
|
383
|
+
return self.request(
|
|
384
|
+
"PUT", path, query_params=query_params, body=body, headers=headers, decode_response=decode_response
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def delete(
|
|
388
|
+
self,
|
|
389
|
+
path: str,
|
|
390
|
+
query_params: Optional[Mapping[str, JsonData]] = None,
|
|
391
|
+
body: str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None = None,
|
|
392
|
+
decode_response: bool = True,
|
|
393
|
+
) -> JsonData | HTTPResponse:
|
|
394
|
+
return self.request("DELETE", path, query_params, body, decode_response=decode_response)
|
|
395
|
+
|
|
396
|
+
def request(
|
|
397
|
+
self,
|
|
398
|
+
method: str,
|
|
399
|
+
path: str,
|
|
400
|
+
query_params: Optional[Mapping[str, JsonData]] = None,
|
|
401
|
+
body: str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None = None,
|
|
402
|
+
maximum_redirects: int = 5,
|
|
403
|
+
decode_response: bool = True,
|
|
404
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
405
|
+
) -> JsonData | HTTPResponse:
|
|
406
|
+
path = self._get_full_path(path)
|
|
407
|
+
extra_headers = headers or {}
|
|
408
|
+
if isinstance(body, (Mapping, list)):
|
|
409
|
+
body = json.dumps(body).encode("utf-8")
|
|
410
|
+
extra_headers = {"Content-Type": "application/json; charset=utf-8"}
|
|
411
|
+
extra_headers = {**extra_headers, **self.get_extra_headers(path, method, body)}
|
|
412
|
+
return self._do_request(method, path, query_params, body, maximum_redirects, extra_headers, decode_response)
|
|
413
|
+
|
|
414
|
+
def get_extra_headers(self, url: str, method: str, body: str | bytes | IO[bytes] | None) -> dict[str, str]:
|
|
415
|
+
return {}
|
|
416
|
+
|
|
417
|
+
def _do_request(
|
|
418
|
+
self,
|
|
419
|
+
method: str,
|
|
420
|
+
path: str,
|
|
421
|
+
query_params: Optional[Mapping[str, JsonData]],
|
|
422
|
+
body: str | bytes | IO[bytes] | None,
|
|
423
|
+
maximum_redirects: int,
|
|
424
|
+
extra_headers: dict[str, str],
|
|
425
|
+
decode_response: bool = True,
|
|
426
|
+
) -> JsonData | HTTPResponse:
|
|
427
|
+
full_uri = path
|
|
428
|
+
if query_params is not None:
|
|
429
|
+
full_uri = f"{path}?{urlencode(query_params, doseq=True)}"
|
|
430
|
+
headers = self._headers.copy()
|
|
431
|
+
if self._proxy_headers:
|
|
432
|
+
headers.update(self._proxy_headers)
|
|
433
|
+
if extra_headers is not None:
|
|
434
|
+
headers.update(extra_headers)
|
|
435
|
+
local_connection = False
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
if logger.is_debugging():
|
|
439
|
+
logger.debug(f"Request: {method} {full_uri}")
|
|
440
|
+
logger.debug("Headers:")
|
|
441
|
+
for key, value in headers.items():
|
|
442
|
+
logger.debug(f"--> {key}: {value}")
|
|
443
|
+
logger.debug("Body:")
|
|
444
|
+
logger.debug(f"--> {body if body is not None else '<no body>'}")
|
|
445
|
+
|
|
446
|
+
# if we weren't called under a `with` statement, we'll need to manage the
|
|
447
|
+
# connection here.
|
|
448
|
+
if self._conn is None:
|
|
449
|
+
self.__enter__()
|
|
450
|
+
local_connection = True
|
|
451
|
+
|
|
452
|
+
# At this point we know that self._conn is not None.
|
|
453
|
+
conn = cast(Union[http.HTTPConnection, http.HTTPSConnection], self._conn)
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
conn.request(method, full_uri, body, headers)
|
|
457
|
+
|
|
458
|
+
response = conn.getresponse()
|
|
459
|
+
response_body = response.read()
|
|
460
|
+
if decode_response:
|
|
461
|
+
response_body = response_body.decode("utf-8").strip()
|
|
462
|
+
|
|
463
|
+
if logger.is_debugging():
|
|
464
|
+
logger.debug(f"Response: {response.status} {response.reason}")
|
|
465
|
+
logger.debug("Headers:")
|
|
466
|
+
for key, value in response.getheaders():
|
|
467
|
+
logger.debug(f"--> {key}: {value}")
|
|
468
|
+
logger.debug("Body:")
|
|
469
|
+
if response.getheader("Content-Type", "").startswith("application/json"):
|
|
470
|
+
# Only print JSON responses.
|
|
471
|
+
# Otherwise we end up dumping entire web pages to the log.
|
|
472
|
+
try:
|
|
473
|
+
logger.debug(f"--> {response_body}")
|
|
474
|
+
except json.JSONDecodeError:
|
|
475
|
+
logger.debug("--> <invalid JSON>")
|
|
476
|
+
else:
|
|
477
|
+
logger.debug("--> <non-json-response>")
|
|
478
|
+
finally:
|
|
479
|
+
if local_connection:
|
|
480
|
+
self.__exit__()
|
|
481
|
+
|
|
482
|
+
# Handle any redirects.
|
|
483
|
+
if 300 <= response.status < 400:
|
|
484
|
+
if maximum_redirects == 0:
|
|
485
|
+
raise http.CannotSendRequest("Too many redirects")
|
|
486
|
+
|
|
487
|
+
location = response.getheader("Location")
|
|
488
|
+
|
|
489
|
+
if location is None:
|
|
490
|
+
raise http.CannotSendRequest("Redirect response missing Location header")
|
|
491
|
+
|
|
492
|
+
# Assume the redirect location will always be on the same domain.
|
|
493
|
+
if location.startswith("http"):
|
|
494
|
+
parsed_location = urlparse(location)
|
|
495
|
+
if parsed_location.query:
|
|
496
|
+
next_url = f"{parsed_location.path}?{parsed_location.query}"
|
|
497
|
+
else:
|
|
498
|
+
next_url = parsed_location.path
|
|
499
|
+
else:
|
|
500
|
+
next_url = location
|
|
501
|
+
|
|
502
|
+
logger.debug(f"--> Redirected to: {urljoin(self._url.geturl(), location)}")
|
|
503
|
+
|
|
504
|
+
redirect_extra_headers = self.get_extra_headers(next_url, "GET", body)
|
|
505
|
+
return self._do_request(
|
|
506
|
+
"GET",
|
|
507
|
+
next_url,
|
|
508
|
+
query_params,
|
|
509
|
+
body,
|
|
510
|
+
maximum_redirects - 1,
|
|
511
|
+
{**extra_headers, **redirect_extra_headers},
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
self._handle_set_cookie(response)
|
|
515
|
+
|
|
516
|
+
return self._tweak_response(HTTPResponse(full_uri, response=response, body=response_body))
|
|
517
|
+
except (
|
|
518
|
+
http.HTTPException,
|
|
519
|
+
ssl.CertificateError,
|
|
520
|
+
IOError,
|
|
521
|
+
OSError,
|
|
522
|
+
socket.error,
|
|
523
|
+
socket.herror,
|
|
524
|
+
socket.gaierror,
|
|
525
|
+
socket.timeout,
|
|
526
|
+
) as exception:
|
|
527
|
+
logger.debug("An exception occurred processing the HTTP request.", exc_info=True)
|
|
528
|
+
return HTTPResponse(full_uri, exception=exception)
|
|
529
|
+
|
|
530
|
+
# noinspection PyMethodMayBeStatic
|
|
531
|
+
def _tweak_response(self, response: HTTPResponse) -> JsonData | HTTPResponse:
|
|
532
|
+
return response
|
|
533
|
+
|
|
534
|
+
def _handle_set_cookie(self, response: http.HTTPResponse):
|
|
535
|
+
self._cookies.store_cookies(response)
|
|
536
|
+
self._inject_cookies()
|
|
537
|
+
|
|
538
|
+
def _inject_cookies(self):
|
|
539
|
+
if len(self._cookies) > 0:
|
|
540
|
+
self._headers["Cookie"] = self._cookies.get_cookie_header_value()
|
|
541
|
+
elif "Cookie" in self._headers:
|
|
542
|
+
del self._headers["Cookie"]
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class CookieJar(object):
|
|
546
|
+
@staticmethod
|
|
547
|
+
def from_dict(source: dict[str, Any]):
|
|
548
|
+
if not isinstance(source, dict):
|
|
549
|
+
raise ValueError("Input must be a dictionary.")
|
|
550
|
+
keys = source.get("keys", [])
|
|
551
|
+
content = source.get("content", {})
|
|
552
|
+
if len(keys) != len(content):
|
|
553
|
+
raise ValueError("Cookie data is mismatched.")
|
|
554
|
+
for key in keys:
|
|
555
|
+
if key not in content:
|
|
556
|
+
raise ValueError("Cookie data is mismatched.")
|
|
557
|
+
result = CookieJar()
|
|
558
|
+
result._keys = keys
|
|
559
|
+
result._content = content
|
|
560
|
+
return result
|
|
561
|
+
|
|
562
|
+
def __init__(self) -> None:
|
|
563
|
+
self._keys: list[str] = []
|
|
564
|
+
self._content: dict[str, str] = {}
|
|
565
|
+
self._reference = SimpleCookie()
|
|
566
|
+
|
|
567
|
+
def store_cookies(self, response: http.HTTPResponse):
|
|
568
|
+
headers = filter(lambda h: h[0].lower() == "set-cookie", response.getheaders())
|
|
569
|
+
|
|
570
|
+
for header in headers:
|
|
571
|
+
cookie = SimpleCookie(header[1])
|
|
572
|
+
for morsel in cookie.values():
|
|
573
|
+
if morsel.key not in self._keys:
|
|
574
|
+
self._keys.append(morsel.key)
|
|
575
|
+
self._content[morsel.key] = morsel.value
|
|
576
|
+
logger.debug(f"--> Set cookie {morsel.key}: {morsel.value}")
|
|
577
|
+
|
|
578
|
+
logger.debug(f"CookieJar contents: {self._keys}\n{self._content}")
|
|
579
|
+
|
|
580
|
+
def get_cookie_header_value(self):
|
|
581
|
+
result = "; ".join([f"{key}={self._reference.value_encode(self._content[key])[1]}" for key in self._keys])
|
|
582
|
+
logger.debug(f"Cookie: {result}")
|
|
583
|
+
return result
|
|
584
|
+
|
|
585
|
+
def as_dict(self):
|
|
586
|
+
return {"keys": list(self._keys), "content": self._content.copy()}
|
|
587
|
+
|
|
588
|
+
def __len__(self):
|
|
589
|
+
return len(self._keys)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
_connection_factory = {
|
|
593
|
+
"http": _create_plain_connection,
|
|
594
|
+
"https": _create_ssl_connection,
|
|
595
|
+
}
|