edq.net.request
1import http 2import logging 3import os 4import time 5import typing 6import urllib.parse 7import urllib3 8 9import requests 10 11import edq.core.errors 12import edq.net.exchange 13import edq.net.exchangeserver 14import edq.net.settings 15import edq.util.dirent 16import edq.util.encoding 17import edq.util.json 18import edq.util.pyimport 19 20_logger = logging.getLogger(__name__) 21 22RETRY_BACKOFF_SECS: float = 0.5 23""" A back-off factor between failed network requests. """ 24 25@typing.runtime_checkable 26class ResponseModifierFunction(typing.Protocol): 27 """ 28 A function that can be used to modify an exchange's response. 29 Exchanges can use these functions to normalize their responses before saving. 30 """ 31 32 def __call__(self, 33 response: requests.Response, 34 body: str, 35 ) -> str: 36 """ 37 Modify the http response. 38 Headers may be modified in the response directly, 39 while the modified (or same) body must be returned. 40 """ 41 42def make_request(method: str, url: str, 43 headers: typing.Union[typing.Dict[str, typing.Any], None] = None, 44 data: typing.Union[typing.Dict[str, typing.Any], None] = None, 45 files: typing.Union[typing.List[typing.Any], None] = None, 46 raise_for_status: bool = True, 47 timeout_secs: typing.Union[float, typing.Tuple[float, float], None] = None, 48 output_dir: typing.Union[str, None] = None, 49 send_anchor_header: bool = True, 50 headers_to_skip: typing.Union[typing.List[str], None] = None, 51 params_to_skip: typing.Union[typing.List[str], None] = None, 52 http_exchange_extension: str = edq.net.exchange.DEFAULT_HTTP_EXCHANGE_EXTENSION, 53 add_http_prefix: bool = True, 54 additional_requests_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 55 allow_redirects: typing.Union[bool, None] = True, 56 retries: int = 0, 57 https_verification: typing.Union[bool, None] = None, 58 request_complete_callback: typing.Union[edq.net.exchange.HTTPExchangeComplete, None] = None, 59 **kwargs: typing.Any) -> typing.Tuple[requests.Response, str]: 60 """ 61 Make an HTTP request and return the response object and text body. 62 63 For `timeout_secs`, see: https://docs.python-requests.org/en/latest/user/advanced/#timeouts 64 """ 65 66 if (add_http_prefix and (not url.lower().startswith('http'))): 67 url = 'http://' + url 68 69 retries = max(0, retries) 70 71 if (output_dir is None): 72 output_dir = edq.net.settings.get_exchanges_out_dir() 73 74 if (headers is None): 75 headers = {} 76 77 if (data is None): 78 data = {} 79 80 if (files is None): 81 files = [] 82 83 if (additional_requests_options is None): 84 additional_requests_options = {} 85 86 if (request_complete_callback is None): 87 raw_callback = edq.net.settings.get_request_complete_callback() 88 if (raw_callback is not None): 89 request_complete_callback = typing.cast(edq.net.exchange.HTTPExchangeComplete, raw_callback) 90 91 # Add in the anchor as a header (since it is not traditionally sent in an HTTP request). 92 if (send_anchor_header): 93 headers = headers.copy() 94 95 parts = urllib.parse.urlparse(url) 96 headers[edq.net.settings.ANCHOR_HEADER_KEY] = parts.fragment.lstrip('#') 97 98 # Compute the full connection/read timeout. 99 if (timeout_secs is None): 100 timeout_secs = (edq.net.settings.get_connection_timeout_secs(), edq.net.settings.get_read_timeout_secs()) 101 elif (isinstance(timeout_secs, float)): 102 timeout_secs = (timeout_secs, timeout_secs) 103 104 options: typing.Dict[str, typing.Any] = { 105 'timeout': timeout_secs, 106 } 107 108 # Check for HTTPS verification. 109 if ((https_verification is False) or ((https_verification is None) and (not edq.net.settings.get_https_verification()))): 110 options['verify'] = False 111 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 112 113 options.update(additional_requests_options) 114 115 options.update({ 116 'headers': headers, 117 'files': files, 118 }) 119 120 if (allow_redirects is False): 121 options['allow_redirects'] = False 122 123 if (method == 'GET'): 124 options['params'] = data 125 else: 126 options['data'] = data 127 128 _logger.debug("Making %s request: '%s' (options = %s).", method, url, options) 129 response = _make_request_with_retry(method, url, options, retries) 130 131 body = response.text 132 if (_logger.level <= logging.DEBUG): 133 log_body = body 134 if (response.encoding is None): 135 log_body = f"<hash> {edq.util.hash.sha256_hex(response.content)}" 136 137 _logger.debug("Response:\n%s", log_body) 138 139 if (raise_for_status): 140 # Handle 404s a little special, as their body may contain useful information. 141 if ((response.status_code == http.HTTPStatus.NOT_FOUND) and (body is not None) and (body.strip() != '')): 142 response.reason += f" (Body: '{body.strip()}')" 143 144 response.raise_for_status() 145 146 exchange = None 147 if ((output_dir is not None) or (request_complete_callback is not None)): 148 exchange = edq.net.exchange.HTTPExchange.from_response( 149 response, 150 headers_to_skip = headers_to_skip, 151 params_to_skip = params_to_skip, 152 allow_redirects = options.get('allow_redirects', True), 153 ) 154 155 if (output_dir is not None): 156 _write_exchange(exchange, output_dir, http_exchange_extension) 157 158 # Also write any redirects. 159 for redirect_response in response.history: 160 redirect_exchange = edq.net.exchange.HTTPExchange.from_response( 161 redirect_response, 162 headers_to_skip = headers_to_skip, 163 params_to_skip = params_to_skip, 164 allow_redirects = options.get('allow_redirects', True), 165 ) 166 _write_exchange(redirect_exchange, output_dir, http_exchange_extension) 167 168 if (request_complete_callback is not None): 169 request_complete_callback(exchange) 170 171 return response, body 172 173def _write_exchange(exchange: edq.net.exchange.HTTPExchange, output_dir: str, http_exchange_extension: str) -> None: 174 """ Write an exchange to disk in the computed path. """ 175 176 relpath = exchange.compute_relpath(http_exchange_extension = http_exchange_extension) 177 path = os.path.abspath(os.path.join(output_dir, relpath)) 178 179 edq.util.dirent.mkdir(os.path.dirname(path)) 180 edq.util.json.dump_path(exchange, path, indent = 4, sort_keys = False) 181 182def make_with_exchange( 183 exchange: edq.net.exchange.HTTPExchange, 184 base_url: str, 185 raise_for_status: bool = True, 186 **kwargs: typing.Any, 187 ) -> typing.Tuple[requests.Response, str]: 188 """ Perform the HTTP request described by the given exchange. """ 189 190 files = [] 191 for file_info in exchange.files: 192 content = file_info.content 193 194 # Content is base64 encoded. 195 if (file_info.b64_encoded and isinstance(content, str)): 196 content = edq.util.encoding.from_base64(content) 197 198 # Content is missing and must be in a file. 199 if (content is None): 200 content = open(file_info.path, 'rb') # type: ignore[assignment,arg-type] # pylint: disable=consider-using-with 201 202 files.append((file_info.name, content)) 203 204 url = f"{base_url}/{exchange.get_url()}" 205 206 response, body = make_request(exchange.method, url, 207 headers = exchange.headers, 208 data = exchange.parameters, 209 files = files, 210 raise_for_status = raise_for_status, 211 allow_redirects = exchange.allow_redirects, 212 **kwargs, 213 ) 214 215 if (exchange.response_modifier is not None): 216 modify_func = edq.util.pyimport.fetch(exchange.response_modifier) 217 body = modify_func(response, body) 218 219 return response, body 220 221 222def make_get(url: str, **kwargs: typing.Any) -> typing.Tuple[requests.Response, str]: 223 """ 224 Make a GET request and return the response object and text body. 225 """ 226 227 return make_request('GET', url, **kwargs) 228 229def make_post(url: str, **kwargs: typing.Any) -> typing.Tuple[requests.Response, str]: 230 """ 231 Make a POST request and return the response object and text body. 232 """ 233 234 return make_request('POST', url, **kwargs) 235 236def _make_request_with_retry( 237 method: str, 238 url: str, 239 options: typing.Dict[str, typing.Any], 240 retries: int, 241 ) -> requests.Response: 242 """ Make a request, retrying on failure. """ 243 244 # Try once and then the number of allowed retries. 245 attempt_count = 1 + retries 246 247 errors = [] 248 for attempt_index in range(attempt_count): 249 if (attempt_index > 0): 250 # Wait before the next retry. 251 time.sleep(attempt_index * RETRY_BACKOFF_SECS) 252 253 try: 254 response = requests.request(method, url, **options) # pylint: disable=missing-timeout 255 break 256 except Exception as ex: 257 errors.append(ex) 258 259 if (len(errors) == attempt_count): 260 raise edq.core.errors.RetryError(f"HTTP {method} for '{url}'", attempt_count, retry_errors = errors) 261 262 return response
RETRY_BACKOFF_SECS: float =
0.5
A back-off factor between failed network requests.
@typing.runtime_checkable
class
ResponseModifierFunction26@typing.runtime_checkable 27class ResponseModifierFunction(typing.Protocol): 28 """ 29 A function that can be used to modify an exchange's response. 30 Exchanges can use these functions to normalize their responses before saving. 31 """ 32 33 def __call__(self, 34 response: requests.Response, 35 body: str, 36 ) -> str: 37 """ 38 Modify the http response. 39 Headers may be modified in the response directly, 40 while the modified (or same) body must be returned. 41 """
A function that can be used to modify an exchange's response. Exchanges can use these functions to normalize their responses before saving.
ResponseModifierFunction(*args, **kwargs)
1953def _no_init_or_replace_init(self, *args, **kwargs): 1954 cls = type(self) 1955 1956 if cls._is_protocol: 1957 raise TypeError('Protocols cannot be instantiated') 1958 1959 # Already using a custom `__init__`. No need to calculate correct 1960 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1961 if cls.__init__ is not _no_init_or_replace_init: 1962 return 1963 1964 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1965 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1966 # searches for a proper new `__init__` in the MRO. The new `__init__` 1967 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1968 # instantiation of the protocol subclass will thus use the new 1969 # `__init__` and no longer call `_no_init_or_replace_init`. 1970 for base in cls.__mro__: 1971 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1972 if init is not _no_init_or_replace_init: 1973 cls.__init__ = init 1974 break 1975 else: 1976 # should not happen 1977 cls.__init__ = object.__init__ 1978 1979 cls.__init__(self, *args, **kwargs)
def
make_request( method: str, url: str, headers: Optional[Dict[str, Any]] = None, data: Optional[Dict[str, Any]] = None, files: Optional[List[Any]] = None, raise_for_status: bool = True, timeout_secs: Union[float, Tuple[float, float], NoneType] = None, output_dir: Optional[str] = None, send_anchor_header: bool = True, headers_to_skip: Optional[List[str]] = None, params_to_skip: Optional[List[str]] = None, http_exchange_extension: str = '.httpex.json', add_http_prefix: bool = True, additional_requests_options: Optional[Dict[str, Any]] = None, allow_redirects: Optional[bool] = True, retries: int = 0, https_verification: Optional[bool] = None, request_complete_callback: Optional[edq.net.exchange.HTTPExchangeComplete] = None, **kwargs: Any) -> Tuple[requests.models.Response, str]:
43def make_request(method: str, url: str, 44 headers: typing.Union[typing.Dict[str, typing.Any], None] = None, 45 data: typing.Union[typing.Dict[str, typing.Any], None] = None, 46 files: typing.Union[typing.List[typing.Any], None] = None, 47 raise_for_status: bool = True, 48 timeout_secs: typing.Union[float, typing.Tuple[float, float], None] = None, 49 output_dir: typing.Union[str, None] = None, 50 send_anchor_header: bool = True, 51 headers_to_skip: typing.Union[typing.List[str], None] = None, 52 params_to_skip: typing.Union[typing.List[str], None] = None, 53 http_exchange_extension: str = edq.net.exchange.DEFAULT_HTTP_EXCHANGE_EXTENSION, 54 add_http_prefix: bool = True, 55 additional_requests_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 56 allow_redirects: typing.Union[bool, None] = True, 57 retries: int = 0, 58 https_verification: typing.Union[bool, None] = None, 59 request_complete_callback: typing.Union[edq.net.exchange.HTTPExchangeComplete, None] = None, 60 **kwargs: typing.Any) -> typing.Tuple[requests.Response, str]: 61 """ 62 Make an HTTP request and return the response object and text body. 63 64 For `timeout_secs`, see: https://docs.python-requests.org/en/latest/user/advanced/#timeouts 65 """ 66 67 if (add_http_prefix and (not url.lower().startswith('http'))): 68 url = 'http://' + url 69 70 retries = max(0, retries) 71 72 if (output_dir is None): 73 output_dir = edq.net.settings.get_exchanges_out_dir() 74 75 if (headers is None): 76 headers = {} 77 78 if (data is None): 79 data = {} 80 81 if (files is None): 82 files = [] 83 84 if (additional_requests_options is None): 85 additional_requests_options = {} 86 87 if (request_complete_callback is None): 88 raw_callback = edq.net.settings.get_request_complete_callback() 89 if (raw_callback is not None): 90 request_complete_callback = typing.cast(edq.net.exchange.HTTPExchangeComplete, raw_callback) 91 92 # Add in the anchor as a header (since it is not traditionally sent in an HTTP request). 93 if (send_anchor_header): 94 headers = headers.copy() 95 96 parts = urllib.parse.urlparse(url) 97 headers[edq.net.settings.ANCHOR_HEADER_KEY] = parts.fragment.lstrip('#') 98 99 # Compute the full connection/read timeout. 100 if (timeout_secs is None): 101 timeout_secs = (edq.net.settings.get_connection_timeout_secs(), edq.net.settings.get_read_timeout_secs()) 102 elif (isinstance(timeout_secs, float)): 103 timeout_secs = (timeout_secs, timeout_secs) 104 105 options: typing.Dict[str, typing.Any] = { 106 'timeout': timeout_secs, 107 } 108 109 # Check for HTTPS verification. 110 if ((https_verification is False) or ((https_verification is None) and (not edq.net.settings.get_https_verification()))): 111 options['verify'] = False 112 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 113 114 options.update(additional_requests_options) 115 116 options.update({ 117 'headers': headers, 118 'files': files, 119 }) 120 121 if (allow_redirects is False): 122 options['allow_redirects'] = False 123 124 if (method == 'GET'): 125 options['params'] = data 126 else: 127 options['data'] = data 128 129 _logger.debug("Making %s request: '%s' (options = %s).", method, url, options) 130 response = _make_request_with_retry(method, url, options, retries) 131 132 body = response.text 133 if (_logger.level <= logging.DEBUG): 134 log_body = body 135 if (response.encoding is None): 136 log_body = f"<hash> {edq.util.hash.sha256_hex(response.content)}" 137 138 _logger.debug("Response:\n%s", log_body) 139 140 if (raise_for_status): 141 # Handle 404s a little special, as their body may contain useful information. 142 if ((response.status_code == http.HTTPStatus.NOT_FOUND) and (body is not None) and (body.strip() != '')): 143 response.reason += f" (Body: '{body.strip()}')" 144 145 response.raise_for_status() 146 147 exchange = None 148 if ((output_dir is not None) or (request_complete_callback is not None)): 149 exchange = edq.net.exchange.HTTPExchange.from_response( 150 response, 151 headers_to_skip = headers_to_skip, 152 params_to_skip = params_to_skip, 153 allow_redirects = options.get('allow_redirects', True), 154 ) 155 156 if (output_dir is not None): 157 _write_exchange(exchange, output_dir, http_exchange_extension) 158 159 # Also write any redirects. 160 for redirect_response in response.history: 161 redirect_exchange = edq.net.exchange.HTTPExchange.from_response( 162 redirect_response, 163 headers_to_skip = headers_to_skip, 164 params_to_skip = params_to_skip, 165 allow_redirects = options.get('allow_redirects', True), 166 ) 167 _write_exchange(redirect_exchange, output_dir, http_exchange_extension) 168 169 if (request_complete_callback is not None): 170 request_complete_callback(exchange) 171 172 return response, body
Make an HTTP request and return the response object and text body.
For timeout_secs, see: https://docs.python-requests.org/en/latest/user/advanced/#timeouts
def
make_with_exchange( exchange: edq.net.exchange.HTTPExchange, base_url: str, raise_for_status: bool = True, **kwargs: Any) -> Tuple[requests.models.Response, str]:
183def make_with_exchange( 184 exchange: edq.net.exchange.HTTPExchange, 185 base_url: str, 186 raise_for_status: bool = True, 187 **kwargs: typing.Any, 188 ) -> typing.Tuple[requests.Response, str]: 189 """ Perform the HTTP request described by the given exchange. """ 190 191 files = [] 192 for file_info in exchange.files: 193 content = file_info.content 194 195 # Content is base64 encoded. 196 if (file_info.b64_encoded and isinstance(content, str)): 197 content = edq.util.encoding.from_base64(content) 198 199 # Content is missing and must be in a file. 200 if (content is None): 201 content = open(file_info.path, 'rb') # type: ignore[assignment,arg-type] # pylint: disable=consider-using-with 202 203 files.append((file_info.name, content)) 204 205 url = f"{base_url}/{exchange.get_url()}" 206 207 response, body = make_request(exchange.method, url, 208 headers = exchange.headers, 209 data = exchange.parameters, 210 files = files, 211 raise_for_status = raise_for_status, 212 allow_redirects = exchange.allow_redirects, 213 **kwargs, 214 ) 215 216 if (exchange.response_modifier is not None): 217 modify_func = edq.util.pyimport.fetch(exchange.response_modifier) 218 body = modify_func(response, body) 219 220 return response, body
Perform the HTTP request described by the given exchange.
def
make_get(url: str, **kwargs: Any) -> Tuple[requests.models.Response, str]:
223def make_get(url: str, **kwargs: typing.Any) -> typing.Tuple[requests.Response, str]: 224 """ 225 Make a GET request and return the response object and text body. 226 """ 227 228 return make_request('GET', url, **kwargs)
Make a GET request and return the response object and text body.
def
make_post(url: str, **kwargs: Any) -> Tuple[requests.models.Response, str]:
230def make_post(url: str, **kwargs: typing.Any) -> typing.Tuple[requests.Response, str]: 231 """ 232 Make a POST request and return the response object and text body. 233 """ 234 235 return make_request('POST', url, **kwargs)
Make a POST request and return the response object and text body.