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.
Files changed (63) hide show
  1. rsconnect/__init__.py +13 -0
  2. rsconnect/actions.py +565 -0
  3. rsconnect/actions_content.py +508 -0
  4. rsconnect/actions_environment.py +160 -0
  5. rsconnect/actions_integration.py +118 -0
  6. rsconnect/api.py +2582 -0
  7. rsconnect/bundle.py +2481 -0
  8. rsconnect/certificates.py +39 -0
  9. rsconnect/environment.py +390 -0
  10. rsconnect/environment_node.py +115 -0
  11. rsconnect/environment_r.py +300 -0
  12. rsconnect/exception.py +15 -0
  13. rsconnect/git_metadata.py +180 -0
  14. rsconnect/http_support.py +595 -0
  15. rsconnect/json_web_token.py +178 -0
  16. rsconnect/log.py +253 -0
  17. rsconnect/main.py +5889 -0
  18. rsconnect/metadata.py +879 -0
  19. rsconnect/models.py +835 -0
  20. rsconnect/oauth.py +623 -0
  21. rsconnect/py.typed +0 -0
  22. rsconnect/pyproject.py +283 -0
  23. rsconnect/quickstart/__init__.py +16 -0
  24. rsconnect/quickstart/quickstart.py +486 -0
  25. rsconnect/quickstart/templates/__init__.py +16 -0
  26. rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
  27. rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
  28. rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
  29. rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
  30. rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
  31. rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
  32. rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
  33. rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
  34. rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
  35. rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
  36. rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
  37. rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
  38. rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
  39. rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
  40. rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
  41. rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
  42. rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
  43. rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
  44. rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
  45. rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
  46. rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
  47. rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
  48. rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
  49. rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
  50. rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
  51. rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
  52. rsconnect/shiny_express.py +136 -0
  53. rsconnect/snowflake.py +93 -0
  54. rsconnect/subprocesses/__init__.py +0 -0
  55. rsconnect/subprocesses/inspect_environment.py +362 -0
  56. rsconnect/timeouts.py +89 -0
  57. rsconnect/utils_package.py +261 -0
  58. rsconnect/validation.py +156 -0
  59. rsconnect/version_check.py +154 -0
  60. rsconnect_python-1.30.0.dist-info/METADATA +89 -0
  61. rsconnect_python-1.30.0.dist-info/RECORD +63 -0
  62. rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
  63. 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
+ }