edq.net.exchange
1import copy 2import http 3import os 4import pathlib 5import typing 6import urllib.parse 7 8import requests 9 10import edq.net.settings 11import edq.net.util 12import edq.util.dirent 13import edq.util.encoding 14import edq.util.hash 15import edq.util.json 16import edq.util.parse 17import edq.util.pyimport 18import edq.util.serial 19 20DEFAULT_HTTP_EXCHANGE_EXTENSION: str= '.httpex.json' 21 22QUERY_CLIP_LENGTH: int = 100 23""" If the filename of an HTTPExhange being saved is longer than this, then clip it. """ 24 25ALLOWED_METHODS: typing.List[str] = [ 26 'DELETE', 27 'GET', 28 'HEAD', 29 'OPTIONS', 30 'PATCH', 31 'POST', 32 'PUT', 33] 34""" Allowed HTTP methods for an HTTPExchange. """ 35 36class FileInfo(edq.util.serial.DictConverter): 37 """ Store info about files used in HTTP exchanges. """ 38 39 def __init__(self, 40 path: typing.Union[str, None] = None, 41 name: typing.Union[str, None] = None, 42 content: typing.Union[str, bytes, None] = None, 43 b64_encoded: bool = False, 44 **kwargs: typing.Any) -> None: 45 # Normalize the path from POSIX-style to the system's style. 46 if (path is not None): 47 path = str(pathlib.PurePath(pathlib.PurePosixPath(path))) 48 49 self.path: typing.Union[str, None] = path 50 """ The on-disk path to a file. """ 51 52 if ((name is None) and (self.path is not None)): 53 name = os.path.basename(self.path) 54 55 if (name is None): 56 raise ValueError("No name was provided for file.") 57 58 self.name: str = name 59 """ The name for this file used in an HTTP request. """ 60 61 self.content: typing.Union[str, bytes, None] = content 62 """ The contents of this file. """ 63 64 self.b64_encoded: bool = b64_encoded 65 """ Whether the content is a string encoded in Base64. """ 66 67 if ((self.path is None) and (self.content is None)): 68 raise ValueError("File must have either path or content specified.") 69 70 def resolve_path(self, base_dir: str, load_file: bool = True) -> None: 71 """ Resolve this path relative to the given base dir. """ 72 73 if ((self.path is not None) and (not os.path.isabs(self.path))): 74 self.path = os.path.abspath(os.path.join(base_dir, self.path)) 75 76 if ((self.path is not None) and (self.content is None) and load_file): 77 self.content = edq.util.dirent.read_file_bytes(self.path) 78 79 def hash_content(self) -> str: 80 """ 81 Compute a hash for the content present. 82 If no content is provided, use the path. 83 """ 84 85 hash_content = self.content 86 87 if (self.b64_encoded and isinstance(hash_content, str)): 88 hash_content = edq.util.encoding.from_base64(hash_content) 89 90 if (hash_content is None): 91 hash_content = self.path 92 93 return edq.util.hash.sha256_hex(hash_content) 94 95 def to_dict(self, 96 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 97 ) -> typing.Dict[str, typing.Any]: 98 data = vars(self).copy() 99 100 # JSON does not support raw bytes, so we will need to base64 encode any binary content. 101 if (isinstance(self.content, bytes)): 102 data['content'] = edq.util.encoding.to_base64(self.content) 103 data['b64_encoded'] = True 104 105 return data 106 107 @classmethod 108 def from_dict(cls, 109 data: typing.Dict[str, typing.Any], 110 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 111 ) -> typing.Any: 112 return FileInfo(**data) 113 114class HTTPExchange(edq.util.serial.DictConverter): 115 """ 116 The request and response making up a full HTTP exchange. 117 """ 118 119 def __init__(self, 120 method: str = 'GET', 121 url: typing.Union[str, None] = None, 122 url_path: typing.Union[str, None] = None, 123 url_anchor: typing.Union[str, None] = None, 124 parameters: typing.Union[typing.Dict[str, typing.Any], None] = None, 125 files: typing.Union[typing.List[typing.Union[FileInfo, typing.Dict[str, typing.Any]]], None] = None, 126 headers: typing.Union[typing.Dict[str, typing.Any], None] = None, 127 allow_redirects: typing.Union[bool, None] = None, 128 response_code: int = http.HTTPStatus.OK, 129 response_headers: typing.Union[typing.Dict[str, typing.Any], None] = None, 130 json_body: typing.Union[bool, None] = None, 131 response_body: typing.Union[str, dict, list, None] = None, 132 source_path: typing.Union[str, None] = None, 133 response_modifier: typing.Union[str, None] = None, 134 finalize: typing.Union[str, None] = None, 135 extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 136 **kwargs: typing.Any) -> None: 137 method = str(method).upper() 138 if (method not in ALLOWED_METHODS): 139 raise ValueError(f"Got unknown/disallowed method: '{method}'.") 140 141 self.method: str = method 142 """ The HTTP method for this exchange. """ 143 144 url_path, url_anchor, parameters = self._parse_url_components(url, url_path, url_anchor, parameters) 145 146 self.url_path: str = url_path 147 """ 148 The path portion of the request URL. 149 Only the path (not domain, port, params, anchor, etc) should be included. 150 """ 151 152 self.url_anchor: typing.Union[str, None] = url_anchor 153 """ 154 The anchor portion of the request URL (if it exists). 155 """ 156 157 self.parameters: typing.Dict[str, typing.Any] = parameters 158 """ 159 The parameters/arguments for this request. 160 Parameters should be provided here and not encoded into URLs, 161 regardless of the request method. 162 With the exception of files, all parameters should be placed here. 163 """ 164 165 if (files is None): 166 files = [] 167 168 parsed_files = [] 169 for file in files: 170 if (isinstance(file, FileInfo)): 171 parsed_files.append(file) 172 else: 173 parsed_files.append(FileInfo(**file)) 174 175 self.files: typing.List[FileInfo] = parsed_files 176 """ 177 A list of files to include in the request. 178 The files are represented as dicts with a 179 "path" (path to the file on disk) and "name" (the filename to send in the request) field. 180 These paths must be POSIX-style paths, 181 they will be converted to system-specific paths. 182 Once this exchange is ready for use, these paths should be resolved (and probably absolute). 183 However, when serialized these paths should probably be relative. 184 To reconcile this, resolve_paths() should be called before using this exchange. 185 """ 186 187 if (headers is None): 188 headers = {} 189 190 self.headers: typing.Dict[str, typing.Any] = {key.lower().strip(): value for (key, value) in headers.items()} 191 """ 192 Headers in the request. 193 All header keys are stored as lower case. 194 """ 195 196 if (allow_redirects is None): 197 allow_redirects = True 198 199 self.allow_redirects: bool = allow_redirects 200 """ Follow redirects. """ 201 202 self.response_code: int = response_code 203 """ The HTTP status code of the response. """ 204 205 if (response_headers is None): 206 response_headers = {} 207 208 self.response_headers: typing.Dict[str, typing.Any] = {key.lower().strip(): value for (key, value) in response_headers.items()} 209 """ 210 Headers in the response. 211 All header keys are stored as lower case. 212 """ 213 214 if (json_body is None): 215 json_body = isinstance(response_body, (dict, list)) 216 217 self.json_body: bool = json_body 218 """ 219 Indicates that the response is JSON and should be converted to/from a string. 220 If the response body is passed in a dict/list and this is passed as None, 221 then this will be set as true. 222 """ 223 224 if (self.json_body and isinstance(response_body, (dict, list))): 225 response_body = edq.util.json.dumps(response_body) 226 227 self.response_body: typing.Union[str, None] = response_body # type: ignore[assignment] 228 """ 229 The response that should be sent in this exchange. 230 """ 231 232 self.response_modifier: typing.Union[str, None] = response_modifier 233 """ 234 This function reference will be used to modify responses (in HTTPExchange.make_request() and HTTPExchange.from_response()) 235 before sent back to the caller. 236 This reference must be importable via edq.util.pyimport.fetch(). 237 """ 238 239 self.finalize: typing.Union[str, None] = finalize 240 """ 241 This function reference will be used to finalize echanges when created using from_response() before sent back to the caller. 242 This reference must be importable via edq.util.pyimport.fetch(). 243 """ 244 245 self.source_path: typing.Union[str, None] = source_path 246 """ 247 The path that this exchange was loaded from (if it was loaded from a file). 248 This value should never be seriald, but can be useful for testing. 249 """ 250 251 if (extra_options is None): 252 extra_options = {} 253 254 self.extra_options: typing.Dict[str, typing.Any] = extra_options.copy() 255 """ 256 Additional options for this exchange. 257 This library will not use these options, but other's may. 258 kwargs will also be added to this. 259 """ 260 261 self.extra_options.update(kwargs) 262 263 def _parse_url_components(self, 264 url: typing.Union[str, None] = None, 265 url_path: typing.Union[str, None] = None, 266 url_anchor: typing.Union[str, None] = None, 267 parameters: typing.Union[typing.Dict[str, typing.Any], None] = None, 268 ) -> typing.Tuple[str, typing.Union[str, None], typing.Dict[str, typing.Any]]: 269 """ 270 Parse out all URL-based components from raw inputs. 271 The URL's path and anchor can either be supplied separately, or as part of the full given URL. 272 If content is present in both places, they much match (or an error will be raised). 273 Query parameters may be provided in the full URL, 274 but will be overwritten by any that are provided separately. 275 Any information from the URL aside from the path, anchor/fragment, and query will be ignored. 276 Note that path parameters (not query parameters) will be ignored. 277 The final url path, url anchor, and parameters will be returned. 278 """ 279 280 # Do base initialization and cleanup. 281 282 if (url_path is not None): 283 url_path = url_path.strip() 284 if (url_path == ''): 285 url_path = '' 286 else: 287 url_path = url_path.lstrip('/') 288 289 if (url_anchor is not None): 290 url_anchor = url_anchor.strip() 291 if (url_anchor == ''): 292 url_anchor = None 293 else: 294 url_anchor = url_anchor.lstrip('#') 295 296 if (parameters is None): 297 parameters = {} 298 299 # Parse the URL (if present). 300 301 if ((url is not None) and (url.strip() != '')): 302 parts = urllib.parse.urlparse(url) 303 304 # Handle the path. 305 306 path = parts.path.lstrip('/') 307 308 if ((url_path is not None) and (url_path != path)): 309 raise ValueError(f"Mismatched URL paths where supplied implicitly ('{path}') and explicitly ('{url_path}').") 310 311 url_path = path 312 313 # Check the optional anchor/fragment. 314 315 if (parts.fragment != ''): 316 fragment = parts.fragment.lstrip('#') 317 318 if ((url_anchor is not None) and (url_anchor != fragment)): 319 raise ValueError(f"Mismatched URL anchors where supplied implicitly ('{fragment}') and explicitly ('{url_anchor}').") 320 321 url_anchor = fragment 322 323 # Check for any parameters. 324 325 url_params = edq.net.util.parse_query_string(parts.query) 326 for (key, value) in url_params.items(): 327 if (key not in parameters): 328 parameters[key] = value 329 330 if (url_path is None): 331 raise ValueError('URL path cannot be empty, it must be explicitly set via `url_path`, or indirectly via `url`.') 332 333 # Sort parameter keys for consistency. 334 parameters = {key: parameters[key] for key in sorted(parameters.keys())} 335 336 return url_path, url_anchor, parameters 337 338 def resolve_paths(self, base_dir: str) -> None: 339 """ Resolve any paths relative to the given base dir. """ 340 341 for file_info in self.files: 342 file_info.resolve_path(base_dir) 343 344 def match(self, query: 'HTTPExchange', 345 match_headers: bool = True, 346 headers_to_skip: typing.Union[typing.List[str], None] = None, 347 params_to_skip: typing.Union[typing.List[str], None] = None, 348 **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]: 349 """ 350 Check if this exchange matches the query exchange. 351 If they match, `(True, None)` will be returned. 352 If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is. 353 354 Note that this is not an equality check, 355 as a query exchange is often missing the response components. 356 This method is often invoked the see if an incoming HTTP request (the query) matches an existing exchange. 357 """ 358 359 if (query.method != self.method): 360 return False, f"HTTP method does not match (query = {query.method}, target = {self.method})." 361 362 if (query.url_path != self.url_path): 363 return False, f"URL path does not match (query = {query.url_path}, target = {self.url_path})." 364 365 if (query.url_anchor != self.url_anchor): 366 return False, f"URL anchor does not match (query = {query.url_anchor}, target = {self.url_anchor})." 367 368 if (headers_to_skip is None): 369 headers_to_skip = edq.net.settings.get_exchanges_ignore_headers() 370 371 if (params_to_skip is None): 372 params_to_skip = [] 373 374 if (match_headers): 375 match, hint = self._match_dict('header', query.headers, self.headers, headers_to_skip) 376 if (not match): 377 return False, hint 378 379 match, hint = self._match_dict('parameter', query.parameters, self.parameters, params_to_skip) 380 if (not match): 381 return False, hint 382 383 # Check file names and hash contents. 384 query_filenames = {(file.name, file.hash_content()) for file in query.files} 385 target_filenames = {(file.name, file.hash_content()) for file in self.files} 386 if (query_filenames != target_filenames): 387 return False, f"File names do not match (query = {query_filenames}, target = {target_filenames})." 388 389 return True, None 390 391 def _match_dict(self, 392 label: str, 393 query_dict: typing.Dict[str, typing.Any], 394 target_dict: typing.Dict[str, typing.Any], 395 keys_to_skip: typing.Union[typing.List[str], None] = None, 396 query_label: str = 'query', 397 target_label: str = 'target', 398 normalize_key_case: bool = True, 399 ) -> typing.Tuple[bool, typing.Union[str, None]]: 400 """ A subcheck in match(), specifically for a dictionary. """ 401 402 if (keys_to_skip is None): 403 keys_to_skip = [] 404 405 if (normalize_key_case): 406 keys_to_skip = [key.lower() for key in keys_to_skip] 407 query_dict = {key.lower(): value for (key, value) in query_dict.items()} 408 target_dict = {key.lower(): value for (key, value) in target_dict.items()} 409 410 query_keys = set(query_dict.keys()) - set(keys_to_skip) 411 target_keys = set(target_dict.keys()) - set(keys_to_skip) 412 413 if (query_keys != target_keys): 414 return False, f"{label.title()} keys do not match ({query_label} = {query_keys}, {target_label} = {target_keys})." 415 416 for key in sorted(query_keys): 417 query_value = query_dict[key] 418 target_value = target_dict[key] 419 420 if (query_value != target_value): 421 comparison = f"{query_label} = '{query_value}', {target_label} = '{target_value}'" 422 return False, f"{label.title()} '{key}' has a non-matching value ({comparison})." 423 424 return True, None 425 426 def get_url(self) -> str: 427 """ Get the URL path and anchor combined. """ 428 429 url = self.url_path 430 431 if (self.url_anchor is not None): 432 url += ('#' + self.url_anchor) 433 434 return url 435 436 def match_response(self, 437 response: requests.Response, 438 override_body: typing.Union[str, None] = None, 439 headers_to_skip: typing.Union[typing.List[str], None] = None, 440 **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]: 441 """ 442 Check if this exchange matches the given response. 443 If they match, `(True, None)` will be returned. 444 If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is. 445 """ 446 447 if (headers_to_skip is None): 448 headers_to_skip = edq.net.settings.get_exchanges_ignore_headers() 449 450 response_body = override_body 451 if (response_body is None): 452 response_body = response.text 453 454 if (self.response_code != response.status_code): 455 return False, f"http status code does match (expected: {self.response_code}, actual: {response.status_code})" 456 457 expected_body = self.response_body 458 actual_body = None 459 460 if (self.json_body): 461 actual_body = response.json() 462 463 # Normalize the actual and expected bodies. 464 465 actual_body = edq.util.json.dumps(actual_body) 466 467 if (isinstance(expected_body, str)): 468 expected_body = edq.util.json.loads(expected_body) 469 470 expected_body = edq.util.json.dumps(expected_body) 471 else: 472 actual_body = response_body 473 474 if (self.response_body != actual_body): 475 body_hint = f"expected: '{self.response_body}', actual: '{actual_body}'" 476 return False, f"body does not match ({body_hint})" 477 478 response_headers = {key.lower().strip(): value for (key, value) in response.headers.items()} 479 match, hint = self._match_dict('header', response_headers, self.response_headers, 480 keys_to_skip = headers_to_skip, 481 query_label = 'response', target_label = 'exchange') 482 483 if (not match): 484 return False, hint 485 486 return True, None 487 488 def compute_relpath(self, http_exchange_extension: str = DEFAULT_HTTP_EXCHANGE_EXTENSION) -> str: 489 """ Create a consistent, semi-unique, and relative path for this exchange. """ 490 491 url = self.get_url().strip() 492 parts = url.split('/') 493 494 if (url in ['', '/']): 495 filename = '_index_' 496 dirname = '' 497 else: 498 filename = parts[-1] 499 500 if (len(parts) > 1): 501 dirname = os.path.join(*parts[0:-1]) 502 else: 503 dirname = '' 504 505 parameters = {} 506 for key in sorted(self.parameters.keys()): 507 parameters[key] = self.parameters[key] 508 509 # Treat files as params as well. 510 for file_info in self.files: 511 parameters[f"file-{file_info.name}"] = file_info.hash_content() 512 513 query = urllib.parse.urlencode(parameters) 514 if (query != ''): 515 # The query can get very long, so we may have to clip it. 516 query_text = edq.util.hash.clip_text(query, QUERY_CLIP_LENGTH) 517 518 # Note that the '?' is URL encoded. 519 filename += f"%3F{query_text}" 520 521 filename += f"_{self.method}{http_exchange_extension}" 522 523 return os.path.join(dirname, filename) 524 525 def to_dict(self, 526 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 527 ) -> typing.Dict[str, typing.Any]: 528 return vars(self) 529 530 @classmethod 531 def from_dict(cls, 532 data: typing.Dict[str, typing.Any], 533 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 534 ) -> typing.Any: 535 return HTTPExchange(**data) 536 537 @classmethod 538 def from_path(cls, 539 path: str, 540 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 541 ) -> 'HTTPExchange': 542 """ 543 Load an exchange from a file. 544 This will also handle setting the exchanges source path (if specified) and resolving the exchange's paths. 545 """ 546 547 if (context is None): 548 context = edq.util.serial.SerializationContext() 549 550 exchange = super().from_path(path, context) 551 552 set_source_path = edq.util.parse.soft_boolean(context.extra.get('set_source_path', True)) 553 if (set_source_path is True): 554 exchange.source_path = os.path.abspath(path) 555 556 exchange.resolve_paths(os.path.abspath(os.path.dirname(path))) # pylint: disable=no-member 557 558 return exchange 559 560 @classmethod 561 def from_response(cls, 562 response: requests.Response, 563 headers_to_skip: typing.Union[typing.List[str], None] = None, 564 params_to_skip: typing.Union[typing.List[str], None] = None, 565 allow_redirects: typing.Union[bool, None] = None, 566 clean_response_func: typing.Union[str, None] = None, 567 finalize_func: typing.Union[str, None] = None, 568 ) -> 'HTTPExchange': 569 """ Create a full exchange from a response. """ 570 571 if (headers_to_skip is None): 572 headers_to_skip = edq.net.settings.get_exchanges_ignore_headers() 573 574 if (params_to_skip is None): 575 params_to_skip = [] 576 577 if (clean_response_func is None): 578 clean_response_func = edq.net.settings.get_exchanges_clean_response_func() 579 580 if (finalize_func is None): 581 finalize_func = edq.net.settings.get_exchanges_finalize_func() 582 583 body = response.text 584 585 # Use a clean function (if one exists). 586 if (clean_response_func is not None): 587 # Make a copy of the response to avoid cleaning functions modifying it. 588 # Note that this is not a very complete solution, since we can't rely on the deep copy getting everything right. 589 response = copy.deepcopy(response) 590 591 modify_func = edq.util.pyimport.fetch(clean_response_func) 592 body = modify_func(response, body) 593 594 request_headers = {key.lower().strip(): value for (key, value) in response.request.headers.items()} 595 response_headers = {key.lower().strip(): value for (key, value) in response.headers.items()} 596 597 request_data, request_files = edq.net.util.parse_request_data( 598 response.request.url, 599 request_headers, 600 response.request.body) # type: ignore[arg-type] 601 602 # Clean headers. 603 for key in headers_to_skip: 604 key = key.lower() 605 606 # Keep the location header if this is an allowed redirect. 607 if (allow_redirects and (key == 'location') and (300 <= response.status_code < 400)): 608 continue 609 610 request_headers.pop(key, None) 611 response_headers.pop(key, None) 612 613 # Clean parameters. 614 for key in params_to_skip: 615 request_data.pop(key, None) 616 617 files = [FileInfo(name = name, content = content) for (name, content) in request_files.items()] 618 619 data: typing.Dict[str, typing.Any] = { 620 'method': response.request.method, 621 'url': response.request.url, 622 'url_anchor': response.request.headers.get(edq.net.settings.ANCHOR_HEADER_KEY, None), 623 'parameters': request_data, 624 'files': files, 625 'headers': request_headers, 626 'response_code': response.status_code, 627 'response_headers': response_headers, 628 'response_body': body, 629 'response_modifier': clean_response_func, 630 'allow_redirects': allow_redirects, 631 } 632 633 exchange = HTTPExchange(**data) 634 635 # Use a finalize function (if one exists). 636 if (finalize_func is not None): 637 finalize_func_object = edq.util.pyimport.fetch(finalize_func) 638 639 exchange = finalize_func_object(exchange) 640 exchange.finalize = finalize_func 641 642 return exchange 643 644@typing.runtime_checkable 645class HTTPExchangeResponseCleanFunc(typing.Protocol): 646 """ 647 A function that can be used to clean a response before creating an exchange. 648 """ 649 650 def __call__(self, 651 response: requests.Response, 652 body: str, 653 ) -> str: 654 """ 655 Take in a response and response body. 656 Clean the response in-place and return the new body. 657 """ 658 659@typing.runtime_checkable 660class HTTPExchangeFinalizeFunc(typing.Protocol): 661 """ 662 A function that can be used to finalize an exchange. 663 """ 664 665 def __call__(self, 666 exchange: HTTPExchange 667 ) -> HTTPExchange: 668 """ 669 Take in an exchange, finalize it, and pass back the finalized exchange (which may be different than the input one). 670 """ 671 672@typing.runtime_checkable 673class HTTPExchangeComplete(typing.Protocol): 674 """ 675 A function that can be called after a request has been made (and exchange constructed). 676 """ 677 678 def __call__(self, 679 exchange: HTTPExchange 680 ) -> str: 681 """ 682 Called after an HTTP exchange has been completed. 683 """
If the filename of an HTTPExhange being saved is longer than this, then clip it.
Allowed HTTP methods for an HTTPExchange.
37class FileInfo(edq.util.serial.DictConverter): 38 """ Store info about files used in HTTP exchanges. """ 39 40 def __init__(self, 41 path: typing.Union[str, None] = None, 42 name: typing.Union[str, None] = None, 43 content: typing.Union[str, bytes, None] = None, 44 b64_encoded: bool = False, 45 **kwargs: typing.Any) -> None: 46 # Normalize the path from POSIX-style to the system's style. 47 if (path is not None): 48 path = str(pathlib.PurePath(pathlib.PurePosixPath(path))) 49 50 self.path: typing.Union[str, None] = path 51 """ The on-disk path to a file. """ 52 53 if ((name is None) and (self.path is not None)): 54 name = os.path.basename(self.path) 55 56 if (name is None): 57 raise ValueError("No name was provided for file.") 58 59 self.name: str = name 60 """ The name for this file used in an HTTP request. """ 61 62 self.content: typing.Union[str, bytes, None] = content 63 """ The contents of this file. """ 64 65 self.b64_encoded: bool = b64_encoded 66 """ Whether the content is a string encoded in Base64. """ 67 68 if ((self.path is None) and (self.content is None)): 69 raise ValueError("File must have either path or content specified.") 70 71 def resolve_path(self, base_dir: str, load_file: bool = True) -> None: 72 """ Resolve this path relative to the given base dir. """ 73 74 if ((self.path is not None) and (not os.path.isabs(self.path))): 75 self.path = os.path.abspath(os.path.join(base_dir, self.path)) 76 77 if ((self.path is not None) and (self.content is None) and load_file): 78 self.content = edq.util.dirent.read_file_bytes(self.path) 79 80 def hash_content(self) -> str: 81 """ 82 Compute a hash for the content present. 83 If no content is provided, use the path. 84 """ 85 86 hash_content = self.content 87 88 if (self.b64_encoded and isinstance(hash_content, str)): 89 hash_content = edq.util.encoding.from_base64(hash_content) 90 91 if (hash_content is None): 92 hash_content = self.path 93 94 return edq.util.hash.sha256_hex(hash_content) 95 96 def to_dict(self, 97 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 98 ) -> typing.Dict[str, typing.Any]: 99 data = vars(self).copy() 100 101 # JSON does not support raw bytes, so we will need to base64 encode any binary content. 102 if (isinstance(self.content, bytes)): 103 data['content'] = edq.util.encoding.to_base64(self.content) 104 data['b64_encoded'] = True 105 106 return data 107 108 @classmethod 109 def from_dict(cls, 110 data: typing.Dict[str, typing.Any], 111 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 112 ) -> typing.Any: 113 return FileInfo(**data)
Store info about files used in HTTP exchanges.
40 def __init__(self, 41 path: typing.Union[str, None] = None, 42 name: typing.Union[str, None] = None, 43 content: typing.Union[str, bytes, None] = None, 44 b64_encoded: bool = False, 45 **kwargs: typing.Any) -> None: 46 # Normalize the path from POSIX-style to the system's style. 47 if (path is not None): 48 path = str(pathlib.PurePath(pathlib.PurePosixPath(path))) 49 50 self.path: typing.Union[str, None] = path 51 """ The on-disk path to a file. """ 52 53 if ((name is None) and (self.path is not None)): 54 name = os.path.basename(self.path) 55 56 if (name is None): 57 raise ValueError("No name was provided for file.") 58 59 self.name: str = name 60 """ The name for this file used in an HTTP request. """ 61 62 self.content: typing.Union[str, bytes, None] = content 63 """ The contents of this file. """ 64 65 self.b64_encoded: bool = b64_encoded 66 """ Whether the content is a string encoded in Base64. """ 67 68 if ((self.path is None) and (self.content is None)): 69 raise ValueError("File must have either path or content specified.")
71 def resolve_path(self, base_dir: str, load_file: bool = True) -> None: 72 """ Resolve this path relative to the given base dir. """ 73 74 if ((self.path is not None) and (not os.path.isabs(self.path))): 75 self.path = os.path.abspath(os.path.join(base_dir, self.path)) 76 77 if ((self.path is not None) and (self.content is None) and load_file): 78 self.content = edq.util.dirent.read_file_bytes(self.path)
Resolve this path relative to the given base dir.
80 def hash_content(self) -> str: 81 """ 82 Compute a hash for the content present. 83 If no content is provided, use the path. 84 """ 85 86 hash_content = self.content 87 88 if (self.b64_encoded and isinstance(hash_content, str)): 89 hash_content = edq.util.encoding.from_base64(hash_content) 90 91 if (hash_content is None): 92 hash_content = self.path 93 94 return edq.util.hash.sha256_hex(hash_content)
Compute a hash for the content present. If no content is provided, use the path.
96 def to_dict(self, 97 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 98 ) -> typing.Dict[str, typing.Any]: 99 data = vars(self).copy() 100 101 # JSON does not support raw bytes, so we will need to base64 encode any binary content. 102 if (isinstance(self.content, bytes)): 103 data['content'] = edq.util.encoding.to_base64(self.content) 104 data['b64_encoded'] = True 105 106 return data
Return a dict that can be used to represent this object. If the dict is passed to from_dict(), an identical object should be reconstructed.
A general (but inefficient) implementation is provided by default.
108 @classmethod 109 def from_dict(cls, 110 data: typing.Dict[str, typing.Any], 111 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 112 ) -> typing.Any: 113 return FileInfo(**data)
Return an instance of this subclass created using the given dict. If the dict came from to_dict(), the returned object should be equivalent to the original.
By default, this function just calls the class' constructor with the output of prep_init_data(). A child can override this or prep_init_data() depending on the functionality they want.
A general (but inefficient) implementation is provided by default. This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.
Inherited Members
115class HTTPExchange(edq.util.serial.DictConverter): 116 """ 117 The request and response making up a full HTTP exchange. 118 """ 119 120 def __init__(self, 121 method: str = 'GET', 122 url: typing.Union[str, None] = None, 123 url_path: typing.Union[str, None] = None, 124 url_anchor: typing.Union[str, None] = None, 125 parameters: typing.Union[typing.Dict[str, typing.Any], None] = None, 126 files: typing.Union[typing.List[typing.Union[FileInfo, typing.Dict[str, typing.Any]]], None] = None, 127 headers: typing.Union[typing.Dict[str, typing.Any], None] = None, 128 allow_redirects: typing.Union[bool, None] = None, 129 response_code: int = http.HTTPStatus.OK, 130 response_headers: typing.Union[typing.Dict[str, typing.Any], None] = None, 131 json_body: typing.Union[bool, None] = None, 132 response_body: typing.Union[str, dict, list, None] = None, 133 source_path: typing.Union[str, None] = None, 134 response_modifier: typing.Union[str, None] = None, 135 finalize: typing.Union[str, None] = None, 136 extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 137 **kwargs: typing.Any) -> None: 138 method = str(method).upper() 139 if (method not in ALLOWED_METHODS): 140 raise ValueError(f"Got unknown/disallowed method: '{method}'.") 141 142 self.method: str = method 143 """ The HTTP method for this exchange. """ 144 145 url_path, url_anchor, parameters = self._parse_url_components(url, url_path, url_anchor, parameters) 146 147 self.url_path: str = url_path 148 """ 149 The path portion of the request URL. 150 Only the path (not domain, port, params, anchor, etc) should be included. 151 """ 152 153 self.url_anchor: typing.Union[str, None] = url_anchor 154 """ 155 The anchor portion of the request URL (if it exists). 156 """ 157 158 self.parameters: typing.Dict[str, typing.Any] = parameters 159 """ 160 The parameters/arguments for this request. 161 Parameters should be provided here and not encoded into URLs, 162 regardless of the request method. 163 With the exception of files, all parameters should be placed here. 164 """ 165 166 if (files is None): 167 files = [] 168 169 parsed_files = [] 170 for file in files: 171 if (isinstance(file, FileInfo)): 172 parsed_files.append(file) 173 else: 174 parsed_files.append(FileInfo(**file)) 175 176 self.files: typing.List[FileInfo] = parsed_files 177 """ 178 A list of files to include in the request. 179 The files are represented as dicts with a 180 "path" (path to the file on disk) and "name" (the filename to send in the request) field. 181 These paths must be POSIX-style paths, 182 they will be converted to system-specific paths. 183 Once this exchange is ready for use, these paths should be resolved (and probably absolute). 184 However, when serialized these paths should probably be relative. 185 To reconcile this, resolve_paths() should be called before using this exchange. 186 """ 187 188 if (headers is None): 189 headers = {} 190 191 self.headers: typing.Dict[str, typing.Any] = {key.lower().strip(): value for (key, value) in headers.items()} 192 """ 193 Headers in the request. 194 All header keys are stored as lower case. 195 """ 196 197 if (allow_redirects is None): 198 allow_redirects = True 199 200 self.allow_redirects: bool = allow_redirects 201 """ Follow redirects. """ 202 203 self.response_code: int = response_code 204 """ The HTTP status code of the response. """ 205 206 if (response_headers is None): 207 response_headers = {} 208 209 self.response_headers: typing.Dict[str, typing.Any] = {key.lower().strip(): value for (key, value) in response_headers.items()} 210 """ 211 Headers in the response. 212 All header keys are stored as lower case. 213 """ 214 215 if (json_body is None): 216 json_body = isinstance(response_body, (dict, list)) 217 218 self.json_body: bool = json_body 219 """ 220 Indicates that the response is JSON and should be converted to/from a string. 221 If the response body is passed in a dict/list and this is passed as None, 222 then this will be set as true. 223 """ 224 225 if (self.json_body and isinstance(response_body, (dict, list))): 226 response_body = edq.util.json.dumps(response_body) 227 228 self.response_body: typing.Union[str, None] = response_body # type: ignore[assignment] 229 """ 230 The response that should be sent in this exchange. 231 """ 232 233 self.response_modifier: typing.Union[str, None] = response_modifier 234 """ 235 This function reference will be used to modify responses (in HTTPExchange.make_request() and HTTPExchange.from_response()) 236 before sent back to the caller. 237 This reference must be importable via edq.util.pyimport.fetch(). 238 """ 239 240 self.finalize: typing.Union[str, None] = finalize 241 """ 242 This function reference will be used to finalize echanges when created using from_response() before sent back to the caller. 243 This reference must be importable via edq.util.pyimport.fetch(). 244 """ 245 246 self.source_path: typing.Union[str, None] = source_path 247 """ 248 The path that this exchange was loaded from (if it was loaded from a file). 249 This value should never be seriald, but can be useful for testing. 250 """ 251 252 if (extra_options is None): 253 extra_options = {} 254 255 self.extra_options: typing.Dict[str, typing.Any] = extra_options.copy() 256 """ 257 Additional options for this exchange. 258 This library will not use these options, but other's may. 259 kwargs will also be added to this. 260 """ 261 262 self.extra_options.update(kwargs) 263 264 def _parse_url_components(self, 265 url: typing.Union[str, None] = None, 266 url_path: typing.Union[str, None] = None, 267 url_anchor: typing.Union[str, None] = None, 268 parameters: typing.Union[typing.Dict[str, typing.Any], None] = None, 269 ) -> typing.Tuple[str, typing.Union[str, None], typing.Dict[str, typing.Any]]: 270 """ 271 Parse out all URL-based components from raw inputs. 272 The URL's path and anchor can either be supplied separately, or as part of the full given URL. 273 If content is present in both places, they much match (or an error will be raised). 274 Query parameters may be provided in the full URL, 275 but will be overwritten by any that are provided separately. 276 Any information from the URL aside from the path, anchor/fragment, and query will be ignored. 277 Note that path parameters (not query parameters) will be ignored. 278 The final url path, url anchor, and parameters will be returned. 279 """ 280 281 # Do base initialization and cleanup. 282 283 if (url_path is not None): 284 url_path = url_path.strip() 285 if (url_path == ''): 286 url_path = '' 287 else: 288 url_path = url_path.lstrip('/') 289 290 if (url_anchor is not None): 291 url_anchor = url_anchor.strip() 292 if (url_anchor == ''): 293 url_anchor = None 294 else: 295 url_anchor = url_anchor.lstrip('#') 296 297 if (parameters is None): 298 parameters = {} 299 300 # Parse the URL (if present). 301 302 if ((url is not None) and (url.strip() != '')): 303 parts = urllib.parse.urlparse(url) 304 305 # Handle the path. 306 307 path = parts.path.lstrip('/') 308 309 if ((url_path is not None) and (url_path != path)): 310 raise ValueError(f"Mismatched URL paths where supplied implicitly ('{path}') and explicitly ('{url_path}').") 311 312 url_path = path 313 314 # Check the optional anchor/fragment. 315 316 if (parts.fragment != ''): 317 fragment = parts.fragment.lstrip('#') 318 319 if ((url_anchor is not None) and (url_anchor != fragment)): 320 raise ValueError(f"Mismatched URL anchors where supplied implicitly ('{fragment}') and explicitly ('{url_anchor}').") 321 322 url_anchor = fragment 323 324 # Check for any parameters. 325 326 url_params = edq.net.util.parse_query_string(parts.query) 327 for (key, value) in url_params.items(): 328 if (key not in parameters): 329 parameters[key] = value 330 331 if (url_path is None): 332 raise ValueError('URL path cannot be empty, it must be explicitly set via `url_path`, or indirectly via `url`.') 333 334 # Sort parameter keys for consistency. 335 parameters = {key: parameters[key] for key in sorted(parameters.keys())} 336 337 return url_path, url_anchor, parameters 338 339 def resolve_paths(self, base_dir: str) -> None: 340 """ Resolve any paths relative to the given base dir. """ 341 342 for file_info in self.files: 343 file_info.resolve_path(base_dir) 344 345 def match(self, query: 'HTTPExchange', 346 match_headers: bool = True, 347 headers_to_skip: typing.Union[typing.List[str], None] = None, 348 params_to_skip: typing.Union[typing.List[str], None] = None, 349 **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]: 350 """ 351 Check if this exchange matches the query exchange. 352 If they match, `(True, None)` will be returned. 353 If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is. 354 355 Note that this is not an equality check, 356 as a query exchange is often missing the response components. 357 This method is often invoked the see if an incoming HTTP request (the query) matches an existing exchange. 358 """ 359 360 if (query.method != self.method): 361 return False, f"HTTP method does not match (query = {query.method}, target = {self.method})." 362 363 if (query.url_path != self.url_path): 364 return False, f"URL path does not match (query = {query.url_path}, target = {self.url_path})." 365 366 if (query.url_anchor != self.url_anchor): 367 return False, f"URL anchor does not match (query = {query.url_anchor}, target = {self.url_anchor})." 368 369 if (headers_to_skip is None): 370 headers_to_skip = edq.net.settings.get_exchanges_ignore_headers() 371 372 if (params_to_skip is None): 373 params_to_skip = [] 374 375 if (match_headers): 376 match, hint = self._match_dict('header', query.headers, self.headers, headers_to_skip) 377 if (not match): 378 return False, hint 379 380 match, hint = self._match_dict('parameter', query.parameters, self.parameters, params_to_skip) 381 if (not match): 382 return False, hint 383 384 # Check file names and hash contents. 385 query_filenames = {(file.name, file.hash_content()) for file in query.files} 386 target_filenames = {(file.name, file.hash_content()) for file in self.files} 387 if (query_filenames != target_filenames): 388 return False, f"File names do not match (query = {query_filenames}, target = {target_filenames})." 389 390 return True, None 391 392 def _match_dict(self, 393 label: str, 394 query_dict: typing.Dict[str, typing.Any], 395 target_dict: typing.Dict[str, typing.Any], 396 keys_to_skip: typing.Union[typing.List[str], None] = None, 397 query_label: str = 'query', 398 target_label: str = 'target', 399 normalize_key_case: bool = True, 400 ) -> typing.Tuple[bool, typing.Union[str, None]]: 401 """ A subcheck in match(), specifically for a dictionary. """ 402 403 if (keys_to_skip is None): 404 keys_to_skip = [] 405 406 if (normalize_key_case): 407 keys_to_skip = [key.lower() for key in keys_to_skip] 408 query_dict = {key.lower(): value for (key, value) in query_dict.items()} 409 target_dict = {key.lower(): value for (key, value) in target_dict.items()} 410 411 query_keys = set(query_dict.keys()) - set(keys_to_skip) 412 target_keys = set(target_dict.keys()) - set(keys_to_skip) 413 414 if (query_keys != target_keys): 415 return False, f"{label.title()} keys do not match ({query_label} = {query_keys}, {target_label} = {target_keys})." 416 417 for key in sorted(query_keys): 418 query_value = query_dict[key] 419 target_value = target_dict[key] 420 421 if (query_value != target_value): 422 comparison = f"{query_label} = '{query_value}', {target_label} = '{target_value}'" 423 return False, f"{label.title()} '{key}' has a non-matching value ({comparison})." 424 425 return True, None 426 427 def get_url(self) -> str: 428 """ Get the URL path and anchor combined. """ 429 430 url = self.url_path 431 432 if (self.url_anchor is not None): 433 url += ('#' + self.url_anchor) 434 435 return url 436 437 def match_response(self, 438 response: requests.Response, 439 override_body: typing.Union[str, None] = None, 440 headers_to_skip: typing.Union[typing.List[str], None] = None, 441 **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]: 442 """ 443 Check if this exchange matches the given response. 444 If they match, `(True, None)` will be returned. 445 If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is. 446 """ 447 448 if (headers_to_skip is None): 449 headers_to_skip = edq.net.settings.get_exchanges_ignore_headers() 450 451 response_body = override_body 452 if (response_body is None): 453 response_body = response.text 454 455 if (self.response_code != response.status_code): 456 return False, f"http status code does match (expected: {self.response_code}, actual: {response.status_code})" 457 458 expected_body = self.response_body 459 actual_body = None 460 461 if (self.json_body): 462 actual_body = response.json() 463 464 # Normalize the actual and expected bodies. 465 466 actual_body = edq.util.json.dumps(actual_body) 467 468 if (isinstance(expected_body, str)): 469 expected_body = edq.util.json.loads(expected_body) 470 471 expected_body = edq.util.json.dumps(expected_body) 472 else: 473 actual_body = response_body 474 475 if (self.response_body != actual_body): 476 body_hint = f"expected: '{self.response_body}', actual: '{actual_body}'" 477 return False, f"body does not match ({body_hint})" 478 479 response_headers = {key.lower().strip(): value for (key, value) in response.headers.items()} 480 match, hint = self._match_dict('header', response_headers, self.response_headers, 481 keys_to_skip = headers_to_skip, 482 query_label = 'response', target_label = 'exchange') 483 484 if (not match): 485 return False, hint 486 487 return True, None 488 489 def compute_relpath(self, http_exchange_extension: str = DEFAULT_HTTP_EXCHANGE_EXTENSION) -> str: 490 """ Create a consistent, semi-unique, and relative path for this exchange. """ 491 492 url = self.get_url().strip() 493 parts = url.split('/') 494 495 if (url in ['', '/']): 496 filename = '_index_' 497 dirname = '' 498 else: 499 filename = parts[-1] 500 501 if (len(parts) > 1): 502 dirname = os.path.join(*parts[0:-1]) 503 else: 504 dirname = '' 505 506 parameters = {} 507 for key in sorted(self.parameters.keys()): 508 parameters[key] = self.parameters[key] 509 510 # Treat files as params as well. 511 for file_info in self.files: 512 parameters[f"file-{file_info.name}"] = file_info.hash_content() 513 514 query = urllib.parse.urlencode(parameters) 515 if (query != ''): 516 # The query can get very long, so we may have to clip it. 517 query_text = edq.util.hash.clip_text(query, QUERY_CLIP_LENGTH) 518 519 # Note that the '?' is URL encoded. 520 filename += f"%3F{query_text}" 521 522 filename += f"_{self.method}{http_exchange_extension}" 523 524 return os.path.join(dirname, filename) 525 526 def to_dict(self, 527 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 528 ) -> typing.Dict[str, typing.Any]: 529 return vars(self) 530 531 @classmethod 532 def from_dict(cls, 533 data: typing.Dict[str, typing.Any], 534 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 535 ) -> typing.Any: 536 return HTTPExchange(**data) 537 538 @classmethod 539 def from_path(cls, 540 path: str, 541 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 542 ) -> 'HTTPExchange': 543 """ 544 Load an exchange from a file. 545 This will also handle setting the exchanges source path (if specified) and resolving the exchange's paths. 546 """ 547 548 if (context is None): 549 context = edq.util.serial.SerializationContext() 550 551 exchange = super().from_path(path, context) 552 553 set_source_path = edq.util.parse.soft_boolean(context.extra.get('set_source_path', True)) 554 if (set_source_path is True): 555 exchange.source_path = os.path.abspath(path) 556 557 exchange.resolve_paths(os.path.abspath(os.path.dirname(path))) # pylint: disable=no-member 558 559 return exchange 560 561 @classmethod 562 def from_response(cls, 563 response: requests.Response, 564 headers_to_skip: typing.Union[typing.List[str], None] = None, 565 params_to_skip: typing.Union[typing.List[str], None] = None, 566 allow_redirects: typing.Union[bool, None] = None, 567 clean_response_func: typing.Union[str, None] = None, 568 finalize_func: typing.Union[str, None] = None, 569 ) -> 'HTTPExchange': 570 """ Create a full exchange from a response. """ 571 572 if (headers_to_skip is None): 573 headers_to_skip = edq.net.settings.get_exchanges_ignore_headers() 574 575 if (params_to_skip is None): 576 params_to_skip = [] 577 578 if (clean_response_func is None): 579 clean_response_func = edq.net.settings.get_exchanges_clean_response_func() 580 581 if (finalize_func is None): 582 finalize_func = edq.net.settings.get_exchanges_finalize_func() 583 584 body = response.text 585 586 # Use a clean function (if one exists). 587 if (clean_response_func is not None): 588 # Make a copy of the response to avoid cleaning functions modifying it. 589 # Note that this is not a very complete solution, since we can't rely on the deep copy getting everything right. 590 response = copy.deepcopy(response) 591 592 modify_func = edq.util.pyimport.fetch(clean_response_func) 593 body = modify_func(response, body) 594 595 request_headers = {key.lower().strip(): value for (key, value) in response.request.headers.items()} 596 response_headers = {key.lower().strip(): value for (key, value) in response.headers.items()} 597 598 request_data, request_files = edq.net.util.parse_request_data( 599 response.request.url, 600 request_headers, 601 response.request.body) # type: ignore[arg-type] 602 603 # Clean headers. 604 for key in headers_to_skip: 605 key = key.lower() 606 607 # Keep the location header if this is an allowed redirect. 608 if (allow_redirects and (key == 'location') and (300 <= response.status_code < 400)): 609 continue 610 611 request_headers.pop(key, None) 612 response_headers.pop(key, None) 613 614 # Clean parameters. 615 for key in params_to_skip: 616 request_data.pop(key, None) 617 618 files = [FileInfo(name = name, content = content) for (name, content) in request_files.items()] 619 620 data: typing.Dict[str, typing.Any] = { 621 'method': response.request.method, 622 'url': response.request.url, 623 'url_anchor': response.request.headers.get(edq.net.settings.ANCHOR_HEADER_KEY, None), 624 'parameters': request_data, 625 'files': files, 626 'headers': request_headers, 627 'response_code': response.status_code, 628 'response_headers': response_headers, 629 'response_body': body, 630 'response_modifier': clean_response_func, 631 'allow_redirects': allow_redirects, 632 } 633 634 exchange = HTTPExchange(**data) 635 636 # Use a finalize function (if one exists). 637 if (finalize_func is not None): 638 finalize_func_object = edq.util.pyimport.fetch(finalize_func) 639 640 exchange = finalize_func_object(exchange) 641 exchange.finalize = finalize_func 642 643 return exchange
The request and response making up a full HTTP exchange.
120 def __init__(self, 121 method: str = 'GET', 122 url: typing.Union[str, None] = None, 123 url_path: typing.Union[str, None] = None, 124 url_anchor: typing.Union[str, None] = None, 125 parameters: typing.Union[typing.Dict[str, typing.Any], None] = None, 126 files: typing.Union[typing.List[typing.Union[FileInfo, typing.Dict[str, typing.Any]]], None] = None, 127 headers: typing.Union[typing.Dict[str, typing.Any], None] = None, 128 allow_redirects: typing.Union[bool, None] = None, 129 response_code: int = http.HTTPStatus.OK, 130 response_headers: typing.Union[typing.Dict[str, typing.Any], None] = None, 131 json_body: typing.Union[bool, None] = None, 132 response_body: typing.Union[str, dict, list, None] = None, 133 source_path: typing.Union[str, None] = None, 134 response_modifier: typing.Union[str, None] = None, 135 finalize: typing.Union[str, None] = None, 136 extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 137 **kwargs: typing.Any) -> None: 138 method = str(method).upper() 139 if (method not in ALLOWED_METHODS): 140 raise ValueError(f"Got unknown/disallowed method: '{method}'.") 141 142 self.method: str = method 143 """ The HTTP method for this exchange. """ 144 145 url_path, url_anchor, parameters = self._parse_url_components(url, url_path, url_anchor, parameters) 146 147 self.url_path: str = url_path 148 """ 149 The path portion of the request URL. 150 Only the path (not domain, port, params, anchor, etc) should be included. 151 """ 152 153 self.url_anchor: typing.Union[str, None] = url_anchor 154 """ 155 The anchor portion of the request URL (if it exists). 156 """ 157 158 self.parameters: typing.Dict[str, typing.Any] = parameters 159 """ 160 The parameters/arguments for this request. 161 Parameters should be provided here and not encoded into URLs, 162 regardless of the request method. 163 With the exception of files, all parameters should be placed here. 164 """ 165 166 if (files is None): 167 files = [] 168 169 parsed_files = [] 170 for file in files: 171 if (isinstance(file, FileInfo)): 172 parsed_files.append(file) 173 else: 174 parsed_files.append(FileInfo(**file)) 175 176 self.files: typing.List[FileInfo] = parsed_files 177 """ 178 A list of files to include in the request. 179 The files are represented as dicts with a 180 "path" (path to the file on disk) and "name" (the filename to send in the request) field. 181 These paths must be POSIX-style paths, 182 they will be converted to system-specific paths. 183 Once this exchange is ready for use, these paths should be resolved (and probably absolute). 184 However, when serialized these paths should probably be relative. 185 To reconcile this, resolve_paths() should be called before using this exchange. 186 """ 187 188 if (headers is None): 189 headers = {} 190 191 self.headers: typing.Dict[str, typing.Any] = {key.lower().strip(): value for (key, value) in headers.items()} 192 """ 193 Headers in the request. 194 All header keys are stored as lower case. 195 """ 196 197 if (allow_redirects is None): 198 allow_redirects = True 199 200 self.allow_redirects: bool = allow_redirects 201 """ Follow redirects. """ 202 203 self.response_code: int = response_code 204 """ The HTTP status code of the response. """ 205 206 if (response_headers is None): 207 response_headers = {} 208 209 self.response_headers: typing.Dict[str, typing.Any] = {key.lower().strip(): value for (key, value) in response_headers.items()} 210 """ 211 Headers in the response. 212 All header keys are stored as lower case. 213 """ 214 215 if (json_body is None): 216 json_body = isinstance(response_body, (dict, list)) 217 218 self.json_body: bool = json_body 219 """ 220 Indicates that the response is JSON and should be converted to/from a string. 221 If the response body is passed in a dict/list and this is passed as None, 222 then this will be set as true. 223 """ 224 225 if (self.json_body and isinstance(response_body, (dict, list))): 226 response_body = edq.util.json.dumps(response_body) 227 228 self.response_body: typing.Union[str, None] = response_body # type: ignore[assignment] 229 """ 230 The response that should be sent in this exchange. 231 """ 232 233 self.response_modifier: typing.Union[str, None] = response_modifier 234 """ 235 This function reference will be used to modify responses (in HTTPExchange.make_request() and HTTPExchange.from_response()) 236 before sent back to the caller. 237 This reference must be importable via edq.util.pyimport.fetch(). 238 """ 239 240 self.finalize: typing.Union[str, None] = finalize 241 """ 242 This function reference will be used to finalize echanges when created using from_response() before sent back to the caller. 243 This reference must be importable via edq.util.pyimport.fetch(). 244 """ 245 246 self.source_path: typing.Union[str, None] = source_path 247 """ 248 The path that this exchange was loaded from (if it was loaded from a file). 249 This value should never be seriald, but can be useful for testing. 250 """ 251 252 if (extra_options is None): 253 extra_options = {} 254 255 self.extra_options: typing.Dict[str, typing.Any] = extra_options.copy() 256 """ 257 Additional options for this exchange. 258 This library will not use these options, but other's may. 259 kwargs will also be added to this. 260 """ 261 262 self.extra_options.update(kwargs)
The path portion of the request URL. Only the path (not domain, port, params, anchor, etc) should be included.
The parameters/arguments for this request. Parameters should be provided here and not encoded into URLs, regardless of the request method. With the exception of files, all parameters should be placed here.
A list of files to include in the request. The files are represented as dicts with a "path" (path to the file on disk) and "name" (the filename to send in the request) field. These paths must be POSIX-style paths, they will be converted to system-specific paths. Once this exchange is ready for use, these paths should be resolved (and probably absolute). However, when serialized these paths should probably be relative. To reconcile this, resolve_paths() should be called before using this exchange.
Indicates that the response is JSON and should be converted to/from a string. If the response body is passed in a dict/list and this is passed as None, then this will be set as true.
This function reference will be used to modify responses (in HTTPExchange.make_request() and HTTPExchange.from_response()) before sent back to the caller. This reference must be importable via edq.util.pyimport.fetch().
This function reference will be used to finalize echanges when created using from_response() before sent back to the caller. This reference must be importable via edq.util.pyimport.fetch().
The path that this exchange was loaded from (if it was loaded from a file). This value should never be seriald, but can be useful for testing.
Additional options for this exchange. This library will not use these options, but other's may. kwargs will also be added to this.
339 def resolve_paths(self, base_dir: str) -> None: 340 """ Resolve any paths relative to the given base dir. """ 341 342 for file_info in self.files: 343 file_info.resolve_path(base_dir)
Resolve any paths relative to the given base dir.
345 def match(self, query: 'HTTPExchange', 346 match_headers: bool = True, 347 headers_to_skip: typing.Union[typing.List[str], None] = None, 348 params_to_skip: typing.Union[typing.List[str], None] = None, 349 **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]: 350 """ 351 Check if this exchange matches the query exchange. 352 If they match, `(True, None)` will be returned. 353 If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is. 354 355 Note that this is not an equality check, 356 as a query exchange is often missing the response components. 357 This method is often invoked the see if an incoming HTTP request (the query) matches an existing exchange. 358 """ 359 360 if (query.method != self.method): 361 return False, f"HTTP method does not match (query = {query.method}, target = {self.method})." 362 363 if (query.url_path != self.url_path): 364 return False, f"URL path does not match (query = {query.url_path}, target = {self.url_path})." 365 366 if (query.url_anchor != self.url_anchor): 367 return False, f"URL anchor does not match (query = {query.url_anchor}, target = {self.url_anchor})." 368 369 if (headers_to_skip is None): 370 headers_to_skip = edq.net.settings.get_exchanges_ignore_headers() 371 372 if (params_to_skip is None): 373 params_to_skip = [] 374 375 if (match_headers): 376 match, hint = self._match_dict('header', query.headers, self.headers, headers_to_skip) 377 if (not match): 378 return False, hint 379 380 match, hint = self._match_dict('parameter', query.parameters, self.parameters, params_to_skip) 381 if (not match): 382 return False, hint 383 384 # Check file names and hash contents. 385 query_filenames = {(file.name, file.hash_content()) for file in query.files} 386 target_filenames = {(file.name, file.hash_content()) for file in self.files} 387 if (query_filenames != target_filenames): 388 return False, f"File names do not match (query = {query_filenames}, target = {target_filenames})." 389 390 return True, None
Check if this exchange matches the query exchange.
If they match, (True, None) will be returned.
If they do not match, (False, <hint>) will be returned, where <hint> points to where the mismatch is.
Note that this is not an equality check, as a query exchange is often missing the response components. This method is often invoked the see if an incoming HTTP request (the query) matches an existing exchange.
427 def get_url(self) -> str: 428 """ Get the URL path and anchor combined. """ 429 430 url = self.url_path 431 432 if (self.url_anchor is not None): 433 url += ('#' + self.url_anchor) 434 435 return url
Get the URL path and anchor combined.
437 def match_response(self, 438 response: requests.Response, 439 override_body: typing.Union[str, None] = None, 440 headers_to_skip: typing.Union[typing.List[str], None] = None, 441 **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]: 442 """ 443 Check if this exchange matches the given response. 444 If they match, `(True, None)` will be returned. 445 If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is. 446 """ 447 448 if (headers_to_skip is None): 449 headers_to_skip = edq.net.settings.get_exchanges_ignore_headers() 450 451 response_body = override_body 452 if (response_body is None): 453 response_body = response.text 454 455 if (self.response_code != response.status_code): 456 return False, f"http status code does match (expected: {self.response_code}, actual: {response.status_code})" 457 458 expected_body = self.response_body 459 actual_body = None 460 461 if (self.json_body): 462 actual_body = response.json() 463 464 # Normalize the actual and expected bodies. 465 466 actual_body = edq.util.json.dumps(actual_body) 467 468 if (isinstance(expected_body, str)): 469 expected_body = edq.util.json.loads(expected_body) 470 471 expected_body = edq.util.json.dumps(expected_body) 472 else: 473 actual_body = response_body 474 475 if (self.response_body != actual_body): 476 body_hint = f"expected: '{self.response_body}', actual: '{actual_body}'" 477 return False, f"body does not match ({body_hint})" 478 479 response_headers = {key.lower().strip(): value for (key, value) in response.headers.items()} 480 match, hint = self._match_dict('header', response_headers, self.response_headers, 481 keys_to_skip = headers_to_skip, 482 query_label = 'response', target_label = 'exchange') 483 484 if (not match): 485 return False, hint 486 487 return True, None
Check if this exchange matches the given response.
If they match, (True, None) will be returned.
If they do not match, (False, <hint>) will be returned, where <hint> points to where the mismatch is.
489 def compute_relpath(self, http_exchange_extension: str = DEFAULT_HTTP_EXCHANGE_EXTENSION) -> str: 490 """ Create a consistent, semi-unique, and relative path for this exchange. """ 491 492 url = self.get_url().strip() 493 parts = url.split('/') 494 495 if (url in ['', '/']): 496 filename = '_index_' 497 dirname = '' 498 else: 499 filename = parts[-1] 500 501 if (len(parts) > 1): 502 dirname = os.path.join(*parts[0:-1]) 503 else: 504 dirname = '' 505 506 parameters = {} 507 for key in sorted(self.parameters.keys()): 508 parameters[key] = self.parameters[key] 509 510 # Treat files as params as well. 511 for file_info in self.files: 512 parameters[f"file-{file_info.name}"] = file_info.hash_content() 513 514 query = urllib.parse.urlencode(parameters) 515 if (query != ''): 516 # The query can get very long, so we may have to clip it. 517 query_text = edq.util.hash.clip_text(query, QUERY_CLIP_LENGTH) 518 519 # Note that the '?' is URL encoded. 520 filename += f"%3F{query_text}" 521 522 filename += f"_{self.method}{http_exchange_extension}" 523 524 return os.path.join(dirname, filename)
Create a consistent, semi-unique, and relative path for this exchange.
526 def to_dict(self, 527 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 528 ) -> typing.Dict[str, typing.Any]: 529 return vars(self)
Return a dict that can be used to represent this object. If the dict is passed to from_dict(), an identical object should be reconstructed.
A general (but inefficient) implementation is provided by default.
531 @classmethod 532 def from_dict(cls, 533 data: typing.Dict[str, typing.Any], 534 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 535 ) -> typing.Any: 536 return HTTPExchange(**data)
Return an instance of this subclass created using the given dict. If the dict came from to_dict(), the returned object should be equivalent to the original.
By default, this function just calls the class' constructor with the output of prep_init_data(). A child can override this or prep_init_data() depending on the functionality they want.
A general (but inefficient) implementation is provided by default. This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.
538 @classmethod 539 def from_path(cls, 540 path: str, 541 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 542 ) -> 'HTTPExchange': 543 """ 544 Load an exchange from a file. 545 This will also handle setting the exchanges source path (if specified) and resolving the exchange's paths. 546 """ 547 548 if (context is None): 549 context = edq.util.serial.SerializationContext() 550 551 exchange = super().from_path(path, context) 552 553 set_source_path = edq.util.parse.soft_boolean(context.extra.get('set_source_path', True)) 554 if (set_source_path is True): 555 exchange.source_path = os.path.abspath(path) 556 557 exchange.resolve_paths(os.path.abspath(os.path.dirname(path))) # pylint: disable=no-member 558 559 return exchange
Load an exchange from a file. This will also handle setting the exchanges source path (if specified) and resolving the exchange's paths.
561 @classmethod 562 def from_response(cls, 563 response: requests.Response, 564 headers_to_skip: typing.Union[typing.List[str], None] = None, 565 params_to_skip: typing.Union[typing.List[str], None] = None, 566 allow_redirects: typing.Union[bool, None] = None, 567 clean_response_func: typing.Union[str, None] = None, 568 finalize_func: typing.Union[str, None] = None, 569 ) -> 'HTTPExchange': 570 """ Create a full exchange from a response. """ 571 572 if (headers_to_skip is None): 573 headers_to_skip = edq.net.settings.get_exchanges_ignore_headers() 574 575 if (params_to_skip is None): 576 params_to_skip = [] 577 578 if (clean_response_func is None): 579 clean_response_func = edq.net.settings.get_exchanges_clean_response_func() 580 581 if (finalize_func is None): 582 finalize_func = edq.net.settings.get_exchanges_finalize_func() 583 584 body = response.text 585 586 # Use a clean function (if one exists). 587 if (clean_response_func is not None): 588 # Make a copy of the response to avoid cleaning functions modifying it. 589 # Note that this is not a very complete solution, since we can't rely on the deep copy getting everything right. 590 response = copy.deepcopy(response) 591 592 modify_func = edq.util.pyimport.fetch(clean_response_func) 593 body = modify_func(response, body) 594 595 request_headers = {key.lower().strip(): value for (key, value) in response.request.headers.items()} 596 response_headers = {key.lower().strip(): value for (key, value) in response.headers.items()} 597 598 request_data, request_files = edq.net.util.parse_request_data( 599 response.request.url, 600 request_headers, 601 response.request.body) # type: ignore[arg-type] 602 603 # Clean headers. 604 for key in headers_to_skip: 605 key = key.lower() 606 607 # Keep the location header if this is an allowed redirect. 608 if (allow_redirects and (key == 'location') and (300 <= response.status_code < 400)): 609 continue 610 611 request_headers.pop(key, None) 612 response_headers.pop(key, None) 613 614 # Clean parameters. 615 for key in params_to_skip: 616 request_data.pop(key, None) 617 618 files = [FileInfo(name = name, content = content) for (name, content) in request_files.items()] 619 620 data: typing.Dict[str, typing.Any] = { 621 'method': response.request.method, 622 'url': response.request.url, 623 'url_anchor': response.request.headers.get(edq.net.settings.ANCHOR_HEADER_KEY, None), 624 'parameters': request_data, 625 'files': files, 626 'headers': request_headers, 627 'response_code': response.status_code, 628 'response_headers': response_headers, 629 'response_body': body, 630 'response_modifier': clean_response_func, 631 'allow_redirects': allow_redirects, 632 } 633 634 exchange = HTTPExchange(**data) 635 636 # Use a finalize function (if one exists). 637 if (finalize_func is not None): 638 finalize_func_object = edq.util.pyimport.fetch(finalize_func) 639 640 exchange = finalize_func_object(exchange) 641 exchange.finalize = finalize_func 642 643 return exchange
Create a full exchange from a response.
Inherited Members
645@typing.runtime_checkable 646class HTTPExchangeResponseCleanFunc(typing.Protocol): 647 """ 648 A function that can be used to clean a response before creating an exchange. 649 """ 650 651 def __call__(self, 652 response: requests.Response, 653 body: str, 654 ) -> str: 655 """ 656 Take in a response and response body. 657 Clean the response in-place and return the new body. 658 """
A function that can be used to clean a response before creating an exchange.
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)
660@typing.runtime_checkable 661class HTTPExchangeFinalizeFunc(typing.Protocol): 662 """ 663 A function that can be used to finalize an exchange. 664 """ 665 666 def __call__(self, 667 exchange: HTTPExchange 668 ) -> HTTPExchange: 669 """ 670 Take in an exchange, finalize it, and pass back the finalized exchange (which may be different than the input one). 671 """
A function that can be used to finalize an exchange.
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)
673@typing.runtime_checkable 674class HTTPExchangeComplete(typing.Protocol): 675 """ 676 A function that can be called after a request has been made (and exchange constructed). 677 """ 678 679 def __call__(self, 680 exchange: HTTPExchange 681 ) -> str: 682 """ 683 Called after an HTTP exchange has been completed. 684 """
A function that can be called after a request has been made (and exchange constructed).
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)