edq.testing.httpserver
1import glob 2import http.server 3import logging 4import os 5import threading 6import time 7import typing 8 9import requests 10 11import edq.testing.unittest 12import edq.util.dirent 13import edq.util.json 14import edq.util.net 15 16SERVER_THREAD_START_WAIT_SEC: float = 0.02 17SERVER_THREAD_REAP_WAIT_SEC: float = 0.15 18 19class HTTPTestServer(): 20 """ 21 An HTTP server meant for testing. 22 This server is generally meant to already know about all the requests that will be made to it, 23 and all the responses it should make in reaction to those respective requests. 24 This allows the server to respond very quickly, and makes it ideal for testing. 25 This makes it easy to mock external services for testing. 26 27 If a request is not found in the predefined requests, 28 then missing_request() will be called. 29 If a response is still not available (indicated by a None return from missing_request()), 30 then an error will be raised. 31 """ 32 33 def __init__(self, 34 port: typing.Union[int, None] = None, 35 match_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 36 default_match_options: bool = True, 37 verbose: bool = False, 38 raise_on_404: bool = False, 39 **kwargs: typing.Any) -> None: 40 self.port: typing.Union[int, None] = port 41 """ 42 The active port this server is using. 43 If None, then a random port will be chosen when the server is started and this field will be populated. 44 """ 45 46 self._http_server: typing.Union[http.server.HTTPServer, None] = None 47 """ The HTTP server listening for connections. """ 48 49 self._thread: typing.Union[threading.Thread, None] = None 50 """ The thread running the HTTP server. """ 51 52 self._run_lock: threading.Lock = threading.Lock() 53 """ A lock that the server holds while running. """ 54 55 self.verbose: bool = verbose 56 """ Log more information. """ 57 58 self.raise_on_404: bool = raise_on_404 59 """ Raise an exception when no exchange is matched (instead of a 404 error). """ 60 61 self._exchanges: typing.Dict[str, typing.Dict[typing.Union[str, None], typing.Dict[str, typing.List[edq.util.net.HTTPExchange]]]] = {} 62 """ 63 The HTTP exchanges (requests+responses) that this server knows about. 64 Exchanges are stored in layers to help make errors for missing requests easier. 65 Exchanges are stored as: {url_path: {anchor: {method: [exchange, ...]}, ...}, ...}. 66 """ 67 68 if (match_options is None): 69 match_options = {} 70 71 if (default_match_options): 72 if ('headers_to_skip' not in match_options): 73 match_options['headers_to_skip'] = [] 74 75 match_options['headers_to_skip'] += edq.util.net.DEFAULT_EXCHANGE_IGNORE_HEADERS 76 77 self.match_options: typing.Dict[str, typing.Any] = match_options.copy() 78 """ Options to use when matching HTTP exchanges. """ 79 80 def get_exchanges(self) -> typing.List[edq.util.net.HTTPExchange]: 81 """ 82 Get a shallow list of all the exchanges in this server. 83 Ordering is not guaranteed. 84 """ 85 86 exchanges = [] 87 88 for url_exchanges in self._exchanges.values(): 89 for anchor_exchanges in url_exchanges.values(): 90 for method_exchanges in anchor_exchanges.values(): 91 exchanges += method_exchanges 92 93 return exchanges 94 95 def start(self) -> None: 96 """ Start this server in a thread and return the port. """ 97 98 class NestedHTTPHandler(_TestHTTPHandler): 99 """ An HTTP handler as a nested class to bind this server object to the handler. """ 100 101 _server = self 102 _verbose = self.verbose 103 _raise_on_404 = self.raise_on_404 104 _missing_request_func = self.missing_request 105 106 if (self.port is None): 107 self.port = edq.util.net.find_open_port() 108 109 self._http_server = http.server.HTTPServer(('', self.port), NestedHTTPHandler) 110 111 if (self.verbose): 112 logging.info("Starting test server on port %d.", self.port) 113 114 # Use a barrier to ensure that the server thread has started. 115 server_startup_barrier = threading.Barrier(2) 116 117 def _run_server(server: 'HTTPTestServer', server_startup_barrier: threading.Barrier) -> None: 118 server_startup_barrier.wait() 119 120 if (server._http_server is None): 121 raise ValueError('Server was not initialized.') 122 123 # Run the server within the run lock context. 124 with server._run_lock: 125 server._http_server.serve_forever(poll_interval = 0.01) 126 server._http_server.server_close() 127 128 if (self.verbose): 129 logging.info("Stopping test server.") 130 131 self._thread = threading.Thread(target = _run_server, args = (self, server_startup_barrier)) 132 self._thread.start() 133 134 # Wait for the server to startup. 135 server_startup_barrier.wait() 136 time.sleep(SERVER_THREAD_START_WAIT_SEC) 137 138 def wait_for_completion(self) -> None: 139 """ 140 Block until the server is not running. 141 If called while the server is not running, this will return immediately. 142 This function will handle keyboard interrupts (Ctrl-C). 143 """ 144 145 try: 146 with self._run_lock: 147 pass 148 except KeyboardInterrupt: 149 self.stop() 150 self.wait_for_completion() 151 152 def start_and_wait(self) -> None: 153 """ Start the server and block until it is done. """ 154 155 self.start() 156 self.wait_for_completion() 157 158 def stop(self) -> None: 159 """ Stop this server. """ 160 161 self.port = None 162 163 if (self._http_server is not None): 164 self._http_server.shutdown() 165 self._http_server = None 166 167 if (self._thread is not None): 168 if (self._thread.is_alive()): 169 self._thread.join(SERVER_THREAD_REAP_WAIT_SEC) 170 171 self._thread = None 172 173 def missing_request(self, query: edq.util.net.HTTPExchange) -> typing.Union[edq.util.net.HTTPExchange, None]: 174 """ 175 Provide the server (specifically, child classes) one last chance to resolve an incoming HTTP request 176 before the server raises an exception. 177 Usually exchanges are loaded from disk, but technically a server can resolve all requests with this method. 178 179 Exchanges returned from this method are not cached/saved. 180 """ 181 182 return None 183 184 def modify_exchanges(self, exchanges: typing.List[edq.util.net.HTTPExchange]) -> typing.List[edq.util.net.HTTPExchange]: 185 """ 186 Modify any exchanges before they are saved into this server's cache. 187 The returned exchanges will be saved in this server's cache. 188 189 This method may be called multiple times with different collections of exchanges. 190 """ 191 192 return exchanges 193 194 def lookup_exchange(self, 195 query: edq.util.net.HTTPExchange, 196 match_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 197 ) -> typing.Tuple[typing.Union[edq.util.net.HTTPExchange, None], typing.Union[str, None]]: 198 """ 199 Lookup the query exchange to see if it exists in this server. 200 If a match exists, the matching exchange (likely a full version of the query) will be returned along with None. 201 If a match does not exist, a None will be returned along with a message indicating how the query missed 202 (e.g., the URL was matched, but the method was not). 203 """ 204 205 if (match_options is None): 206 match_options = {} 207 208 hint_display = query.url_path 209 target: typing.Any = self._exchanges 210 211 if (query.url_path not in target): 212 return None, f"Could not find matching URL path for '{hint_display}'." 213 214 hint_display = query.url_path 215 if (query.url_anchor is not None): 216 hint_display = f"{query.url_path}#{query.url_anchor}" 217 218 target = target[query.url_path] 219 220 if (query.url_anchor not in target): 221 return None, f"Found URL path, but could not find matching anchor for '{hint_display}'." 222 223 hint_display = f"{hint_display} ({query.method})" 224 target = target[query.url_anchor] 225 226 if (query.method not in target): 227 return None, f"Found URL, but could not find matching method for '{hint_display}'." 228 229 params = list(sorted(query.parameters.keys())) 230 hint_display = f"{hint_display}, (param keys = {params})" 231 target = target[query.method] 232 233 full_match_options = self.match_options.copy() 234 full_match_options.update(match_options) 235 236 hints = [] 237 matches = [] 238 239 for (i, exchange) in enumerate(target): 240 match, hint = exchange.match(query, **full_match_options) 241 if (match): 242 matches.append(exchange) 243 continue 244 245 # Collect hints for non-matches. 246 label = exchange.source_path 247 if (label is None): 248 label = str(i) 249 250 hints.append(f"{label}: {hint}") 251 252 if (len(matches) == 1): 253 # Found exactly one match. 254 return matches[0], None 255 256 if (len(matches) > 1): 257 # Found multiple matches. 258 match_paths = list(sorted([match.source_path for match in matches])) 259 return None, f"Found multiple matching exchanges for '{hint_display}': {match_paths}." 260 261 # Found no matches. 262 return None, f"Found matching URL and method, but could not find matching headers/params for '{hint_display}' (non-matching hints: {hints})." 263 264 def load_exchange(self, exchange: edq.util.net.HTTPExchange) -> None: 265 """ Load an exchange into this server. """ 266 267 if (exchange is None): 268 raise ValueError("Cannot load a None exchange.") 269 270 target: typing.Any = self._exchanges 271 if (exchange.url_path not in target): 272 target[exchange.url_path] = {} 273 274 target = target[exchange.url_path] 275 if (exchange.url_anchor not in target): 276 target[exchange.url_anchor] = {} 277 278 target = target[exchange.url_anchor] 279 if (exchange.method not in target): 280 target[exchange.method] = [] 281 282 target = target[exchange.method] 283 target.append(exchange) 284 285 def load_exchange_file(self, path: str) -> None: 286 """ 287 Load an exchange from a file. 288 This will also handle setting the exchanges source path and resolving the exchange's paths. 289 """ 290 291 self.load_exchange(edq.util.net.HTTPExchange.from_path(path)) 292 293 def load_exchanges_dir(self, base_dir: str, extension: str = edq.util.net.DEFAULT_HTTP_EXCHANGE_EXTENSION) -> None: 294 """ Load all exchanges found (recursively) within a directory. """ 295 296 paths = list(sorted(glob.glob(os.path.join(base_dir, "**", f"*{extension}"), recursive = True))) 297 for path in paths: 298 self.load_exchange_file(path) 299 300@typing.runtime_checkable 301class MissingRequestFunction(typing.Protocol): 302 """ 303 A function that can be used to create an exchange when none was found. 304 This is often the last resort when a test server cannot find an exchange matching a request. 305 """ 306 307 def __call__(self, 308 query: edq.util.net.HTTPExchange, 309 ) -> typing.Union[edq.util.net.HTTPExchange, None]: 310 """ 311 Create an exchange for the given query or return None. 312 """ 313 314class _TestHTTPHandler(http.server.BaseHTTPRequestHandler): 315 _server: typing.Union[HTTPTestServer, None] = None 316 """ The test server this handler is being used for. """ 317 318 _verbose: bool = False 319 """ Log more interactions. """ 320 321 _raise_on_404: bool = True 322 """ Raise an exception when no exchange is matched (instead of a 404 error). """ 323 324 _missing_request_func: typing.Union[MissingRequestFunction, None] = None 325 """ A fallback to get an exchange before resulting in a 404. """ 326 327 # Quiet logs. 328 def log_message(self, format: str, *args: typing.Any) -> None: # pylint: disable=redefined-builtin 329 pass 330 331 def do_DELETE(self) -> None: # pylint: disable=invalid-name 332 """ A handler for DELETE requests. """ 333 334 self._do_request('DELETE') 335 336 def do_GET(self) -> None: # pylint: disable=invalid-name 337 """ A handler for GET requests. """ 338 339 self._do_request('GET') 340 341 def do_HEAD(self) -> None: # pylint: disable=invalid-name 342 """ A handler for HEAD requests. """ 343 344 self._do_request('HEAD') 345 346 def do_OPTIONS(self) -> None: # pylint: disable=invalid-name 347 """ A handler for OPTIONS requests. """ 348 349 self._do_request('OPTIONS') 350 351 def do_PATCH(self) -> None: # pylint: disable=invalid-name 352 """ A handler for PATCH requests. """ 353 354 self._do_request('PATCH') 355 356 def do_POST(self) -> None: # pylint: disable=invalid-name 357 """ A handler for POST requests. """ 358 359 self._do_request('POST') 360 361 def do_PUT(self) -> None: # pylint: disable=invalid-name 362 """ A handler for PUT requests. """ 363 364 self._do_request('PUT') 365 366 def _do_request(self, method: str) -> None: 367 """ A common handler for multiple types of requests. """ 368 369 if (self._server is None): 370 raise ValueError("Server has not been initialized.") 371 372 if (self._verbose): 373 logging.debug("Incoming %s request: '%s'.", method, self.path) 374 375 # Parse data from the request url and body. 376 request_data, request_files = edq.util.net.parse_request_data(self.path, self.headers, self.rfile) 377 378 # Construct file info objects from the raw files. 379 files = [edq.util.net.FileInfo(name = name, content = content) for (name, content) in request_files.items()] 380 381 exchange, hint = self._get_exchange(method, parameters = request_data, files = files) # type: ignore[arg-type] 382 383 if (exchange is None): 384 code = http.HTTPStatus.NOT_FOUND.value 385 headers = {} 386 payload = hint 387 else: 388 code = exchange.response_code 389 headers = exchange.response_headers 390 payload = exchange.response_body 391 392 if (payload is None): 393 payload = '' 394 395 self.send_response(code) 396 for (key, value) in headers.items(): 397 self.send_header(key, value) 398 self.end_headers() 399 400 self.wfile.write(payload.encode(edq.util.dirent.DEFAULT_ENCODING)) 401 402 def _get_exchange(self, method: str, 403 parameters: typing.Union[typing.Dict[str, typing.Any], None] = None, 404 files: typing.Union[typing.List[typing.Union[edq.util.net.FileInfo, typing.Dict[str, str]]], None] = None, 405 ) -> typing.Tuple[typing.Union[edq.util.net.HTTPExchange, None], typing.Union[str, None]]: 406 """ Get the matching exchange or raise an error. """ 407 408 if (self._server is None): 409 raise ValueError("Server has not been initialized.") 410 411 query = edq.util.net.HTTPExchange(method = method, 412 url = self.path, 413 url_anchor = self.headers.get(edq.util.net.ANCHOR_HEADER_KEY, None), 414 headers = self.headers, # type: ignore[arg-type] 415 parameters = parameters, files = files) 416 417 exchange, hint = self._server.lookup_exchange(query) 418 419 if ((exchange is None) and (self._missing_request_func is not None)): 420 exchange = self._missing_request_func(query) # pylint: disable=not-callable 421 422 if ((exchange is None) and self._raise_on_404): 423 raise ValueError(f"Failed to lookup exchange: '{hint}'.") 424 425 return exchange, hint 426 427class HTTPServerTest(edq.testing.unittest.BaseTest): 428 """ 429 A unit test class that requires a testing HTTP server to be running. 430 """ 431 432 server_key: str = '' 433 """ 434 A key to indicate which test server this test class is using. 435 By default all test classes share the same server, 436 but child classes can set this if they want to control who is using the same server. 437 If `tear_down_server` is true, then the relevant server will be stopped (and removed) on a call to tearDownClass(), 438 which happens after a test class is complete. 439 """ 440 441 tear_down_server: bool = True 442 """ 443 Tear down the relevant test server in tearDownClass(). 444 If set to false then the server will never get torn down, 445 but can be shared between child test classes. 446 """ 447 448 skip_test_exchanges_base: bool = False 449 """ Skip test_exchanges_base. """ 450 451 _servers: typing.Dict[str, HTTPTestServer] = {} 452 """ The active test servers. """ 453 454 _complete_exchange_tests: typing.Set[str] = set() 455 """ 456 Keep track of the servers (by key) that have run their test_exchanges_base. 457 This test should only be run once per server. 458 """ 459 460 _child_class_setup_called: bool = False 461 """ Keep track if the child class setup was called. """ 462 463 @classmethod 464 def setUpClass(cls) -> None: 465 if (not cls._child_class_setup_called): 466 cls.child_class_setup() 467 cls._child_class_setup_called = True 468 469 if (cls.server_key in cls._servers): 470 return 471 472 server = HTTPTestServer() 473 cls._servers[cls.server_key] = server 474 475 cls.setup_server(server) 476 server.start() 477 cls.post_start_server(server) 478 479 @classmethod 480 def tearDownClass(cls) -> None: 481 if (cls.server_key not in cls._servers): 482 return 483 484 server = cls.get_server() 485 486 if (cls.tear_down_server): 487 server.stop() 488 del cls._servers[cls.server_key] 489 cls._complete_exchange_tests.discard(cls.server_key) 490 491 @classmethod 492 def suite_cleanup(cls) -> None: 493 """ Cleanup all test servers. """ 494 495 for server in cls._servers.values(): 496 server.stop() 497 498 cls._servers.clear() 499 500 @classmethod 501 def get_server(cls) -> HTTPTestServer: 502 """ Get the current HTTP server or raise if there is no server. """ 503 504 server = cls._servers.get(cls.server_key, None) 505 if (server is None): 506 raise ValueError("Server has not been initialized.") 507 508 return server 509 510 @classmethod 511 def child_class_setup(cls) -> None: 512 """ This function is the recommended time for child classes to set any configuration. """ 513 514 @classmethod 515 def setup_server(cls, server: HTTPTestServer) -> None: 516 """ An opportunity for child classes to configure the test server before starting it. """ 517 518 @classmethod 519 def post_start_server(cls, server: HTTPTestServer) -> None: 520 """ An opportunity for child classes to work with the server after it has been started, but before any tests. """ 521 522 @classmethod 523 def get_server_url(cls) -> str: 524 """ Get the URL for this test's test server. """ 525 526 server = cls.get_server() 527 528 if (server.port is None): 529 raise ValueError("Test server port has not been set.") 530 531 return f"http://127.0.0.1:{server.port}" 532 533 def assert_exchange(self, request: edq.util.net.HTTPExchange, response: edq.util.net.HTTPExchange, 534 base_url: typing.Union[str, None] = None, 535 ) -> requests.Response: 536 """ 537 Assert that the result of making the provided request matches the provided response. 538 The same HTTPExchange may be supplied for both the request and response. 539 By default, the server's URL will be used as the base URL. 540 The full response will be returned (if no assertion is raised). 541 """ 542 543 server = self.get_server() 544 545 if (base_url is None): 546 base_url = self.get_server_url() 547 548 full_response, body = request.make_request(base_url, raise_for_status = True, **server.match_options) 549 550 match, hint = response.match_response(full_response, override_body = body, **server.match_options) 551 if (not match): 552 raise AssertionError(f"Exchange does not match: '{hint}'.") 553 554 return full_response 555 556 def test_exchanges_base(self) -> None: 557 """ Test making a request with each of the loaded exchanges. """ 558 559 # Check if this test has already been run for this server. 560 if (self.server_key in self._complete_exchange_tests): 561 # Don't skip the test (which will show up in the test output). 562 # Instead, just return. 563 return 564 565 if (self.skip_test_exchanges_base): 566 self.skipTest('test_exchanges_base has been manually skipped.') 567 568 self._complete_exchange_tests.add(self.server_key) 569 570 server = self.get_server() 571 572 for (i, exchange) in enumerate(server.get_exchanges()): 573 base_name = exchange.get_url() 574 if (exchange.source_path is not None): 575 base_name = os.path.splitext(os.path.basename(exchange.source_path))[0] 576 577 with self.subTest(msg = f"Case {i} ({base_name}):"): 578 self.assert_exchange(exchange, exchange)
20class HTTPTestServer(): 21 """ 22 An HTTP server meant for testing. 23 This server is generally meant to already know about all the requests that will be made to it, 24 and all the responses it should make in reaction to those respective requests. 25 This allows the server to respond very quickly, and makes it ideal for testing. 26 This makes it easy to mock external services for testing. 27 28 If a request is not found in the predefined requests, 29 then missing_request() will be called. 30 If a response is still not available (indicated by a None return from missing_request()), 31 then an error will be raised. 32 """ 33 34 def __init__(self, 35 port: typing.Union[int, None] = None, 36 match_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 37 default_match_options: bool = True, 38 verbose: bool = False, 39 raise_on_404: bool = False, 40 **kwargs: typing.Any) -> None: 41 self.port: typing.Union[int, None] = port 42 """ 43 The active port this server is using. 44 If None, then a random port will be chosen when the server is started and this field will be populated. 45 """ 46 47 self._http_server: typing.Union[http.server.HTTPServer, None] = None 48 """ The HTTP server listening for connections. """ 49 50 self._thread: typing.Union[threading.Thread, None] = None 51 """ The thread running the HTTP server. """ 52 53 self._run_lock: threading.Lock = threading.Lock() 54 """ A lock that the server holds while running. """ 55 56 self.verbose: bool = verbose 57 """ Log more information. """ 58 59 self.raise_on_404: bool = raise_on_404 60 """ Raise an exception when no exchange is matched (instead of a 404 error). """ 61 62 self._exchanges: typing.Dict[str, typing.Dict[typing.Union[str, None], typing.Dict[str, typing.List[edq.util.net.HTTPExchange]]]] = {} 63 """ 64 The HTTP exchanges (requests+responses) that this server knows about. 65 Exchanges are stored in layers to help make errors for missing requests easier. 66 Exchanges are stored as: {url_path: {anchor: {method: [exchange, ...]}, ...}, ...}. 67 """ 68 69 if (match_options is None): 70 match_options = {} 71 72 if (default_match_options): 73 if ('headers_to_skip' not in match_options): 74 match_options['headers_to_skip'] = [] 75 76 match_options['headers_to_skip'] += edq.util.net.DEFAULT_EXCHANGE_IGNORE_HEADERS 77 78 self.match_options: typing.Dict[str, typing.Any] = match_options.copy() 79 """ Options to use when matching HTTP exchanges. """ 80 81 def get_exchanges(self) -> typing.List[edq.util.net.HTTPExchange]: 82 """ 83 Get a shallow list of all the exchanges in this server. 84 Ordering is not guaranteed. 85 """ 86 87 exchanges = [] 88 89 for url_exchanges in self._exchanges.values(): 90 for anchor_exchanges in url_exchanges.values(): 91 for method_exchanges in anchor_exchanges.values(): 92 exchanges += method_exchanges 93 94 return exchanges 95 96 def start(self) -> None: 97 """ Start this server in a thread and return the port. """ 98 99 class NestedHTTPHandler(_TestHTTPHandler): 100 """ An HTTP handler as a nested class to bind this server object to the handler. """ 101 102 _server = self 103 _verbose = self.verbose 104 _raise_on_404 = self.raise_on_404 105 _missing_request_func = self.missing_request 106 107 if (self.port is None): 108 self.port = edq.util.net.find_open_port() 109 110 self._http_server = http.server.HTTPServer(('', self.port), NestedHTTPHandler) 111 112 if (self.verbose): 113 logging.info("Starting test server on port %d.", self.port) 114 115 # Use a barrier to ensure that the server thread has started. 116 server_startup_barrier = threading.Barrier(2) 117 118 def _run_server(server: 'HTTPTestServer', server_startup_barrier: threading.Barrier) -> None: 119 server_startup_barrier.wait() 120 121 if (server._http_server is None): 122 raise ValueError('Server was not initialized.') 123 124 # Run the server within the run lock context. 125 with server._run_lock: 126 server._http_server.serve_forever(poll_interval = 0.01) 127 server._http_server.server_close() 128 129 if (self.verbose): 130 logging.info("Stopping test server.") 131 132 self._thread = threading.Thread(target = _run_server, args = (self, server_startup_barrier)) 133 self._thread.start() 134 135 # Wait for the server to startup. 136 server_startup_barrier.wait() 137 time.sleep(SERVER_THREAD_START_WAIT_SEC) 138 139 def wait_for_completion(self) -> None: 140 """ 141 Block until the server is not running. 142 If called while the server is not running, this will return immediately. 143 This function will handle keyboard interrupts (Ctrl-C). 144 """ 145 146 try: 147 with self._run_lock: 148 pass 149 except KeyboardInterrupt: 150 self.stop() 151 self.wait_for_completion() 152 153 def start_and_wait(self) -> None: 154 """ Start the server and block until it is done. """ 155 156 self.start() 157 self.wait_for_completion() 158 159 def stop(self) -> None: 160 """ Stop this server. """ 161 162 self.port = None 163 164 if (self._http_server is not None): 165 self._http_server.shutdown() 166 self._http_server = None 167 168 if (self._thread is not None): 169 if (self._thread.is_alive()): 170 self._thread.join(SERVER_THREAD_REAP_WAIT_SEC) 171 172 self._thread = None 173 174 def missing_request(self, query: edq.util.net.HTTPExchange) -> typing.Union[edq.util.net.HTTPExchange, None]: 175 """ 176 Provide the server (specifically, child classes) one last chance to resolve an incoming HTTP request 177 before the server raises an exception. 178 Usually exchanges are loaded from disk, but technically a server can resolve all requests with this method. 179 180 Exchanges returned from this method are not cached/saved. 181 """ 182 183 return None 184 185 def modify_exchanges(self, exchanges: typing.List[edq.util.net.HTTPExchange]) -> typing.List[edq.util.net.HTTPExchange]: 186 """ 187 Modify any exchanges before they are saved into this server's cache. 188 The returned exchanges will be saved in this server's cache. 189 190 This method may be called multiple times with different collections of exchanges. 191 """ 192 193 return exchanges 194 195 def lookup_exchange(self, 196 query: edq.util.net.HTTPExchange, 197 match_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 198 ) -> typing.Tuple[typing.Union[edq.util.net.HTTPExchange, None], typing.Union[str, None]]: 199 """ 200 Lookup the query exchange to see if it exists in this server. 201 If a match exists, the matching exchange (likely a full version of the query) will be returned along with None. 202 If a match does not exist, a None will be returned along with a message indicating how the query missed 203 (e.g., the URL was matched, but the method was not). 204 """ 205 206 if (match_options is None): 207 match_options = {} 208 209 hint_display = query.url_path 210 target: typing.Any = self._exchanges 211 212 if (query.url_path not in target): 213 return None, f"Could not find matching URL path for '{hint_display}'." 214 215 hint_display = query.url_path 216 if (query.url_anchor is not None): 217 hint_display = f"{query.url_path}#{query.url_anchor}" 218 219 target = target[query.url_path] 220 221 if (query.url_anchor not in target): 222 return None, f"Found URL path, but could not find matching anchor for '{hint_display}'." 223 224 hint_display = f"{hint_display} ({query.method})" 225 target = target[query.url_anchor] 226 227 if (query.method not in target): 228 return None, f"Found URL, but could not find matching method for '{hint_display}'." 229 230 params = list(sorted(query.parameters.keys())) 231 hint_display = f"{hint_display}, (param keys = {params})" 232 target = target[query.method] 233 234 full_match_options = self.match_options.copy() 235 full_match_options.update(match_options) 236 237 hints = [] 238 matches = [] 239 240 for (i, exchange) in enumerate(target): 241 match, hint = exchange.match(query, **full_match_options) 242 if (match): 243 matches.append(exchange) 244 continue 245 246 # Collect hints for non-matches. 247 label = exchange.source_path 248 if (label is None): 249 label = str(i) 250 251 hints.append(f"{label}: {hint}") 252 253 if (len(matches) == 1): 254 # Found exactly one match. 255 return matches[0], None 256 257 if (len(matches) > 1): 258 # Found multiple matches. 259 match_paths = list(sorted([match.source_path for match in matches])) 260 return None, f"Found multiple matching exchanges for '{hint_display}': {match_paths}." 261 262 # Found no matches. 263 return None, f"Found matching URL and method, but could not find matching headers/params for '{hint_display}' (non-matching hints: {hints})." 264 265 def load_exchange(self, exchange: edq.util.net.HTTPExchange) -> None: 266 """ Load an exchange into this server. """ 267 268 if (exchange is None): 269 raise ValueError("Cannot load a None exchange.") 270 271 target: typing.Any = self._exchanges 272 if (exchange.url_path not in target): 273 target[exchange.url_path] = {} 274 275 target = target[exchange.url_path] 276 if (exchange.url_anchor not in target): 277 target[exchange.url_anchor] = {} 278 279 target = target[exchange.url_anchor] 280 if (exchange.method not in target): 281 target[exchange.method] = [] 282 283 target = target[exchange.method] 284 target.append(exchange) 285 286 def load_exchange_file(self, path: str) -> None: 287 """ 288 Load an exchange from a file. 289 This will also handle setting the exchanges source path and resolving the exchange's paths. 290 """ 291 292 self.load_exchange(edq.util.net.HTTPExchange.from_path(path)) 293 294 def load_exchanges_dir(self, base_dir: str, extension: str = edq.util.net.DEFAULT_HTTP_EXCHANGE_EXTENSION) -> None: 295 """ Load all exchanges found (recursively) within a directory. """ 296 297 paths = list(sorted(glob.glob(os.path.join(base_dir, "**", f"*{extension}"), recursive = True))) 298 for path in paths: 299 self.load_exchange_file(path)
An HTTP server meant for testing. This server is generally meant to already know about all the requests that will be made to it, and all the responses it should make in reaction to those respective requests. This allows the server to respond very quickly, and makes it ideal for testing. This makes it easy to mock external services for testing.
If a request is not found in the predefined requests, then missing_request() will be called. If a response is still not available (indicated by a None return from missing_request()), then an error will be raised.
34 def __init__(self, 35 port: typing.Union[int, None] = None, 36 match_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 37 default_match_options: bool = True, 38 verbose: bool = False, 39 raise_on_404: bool = False, 40 **kwargs: typing.Any) -> None: 41 self.port: typing.Union[int, None] = port 42 """ 43 The active port this server is using. 44 If None, then a random port will be chosen when the server is started and this field will be populated. 45 """ 46 47 self._http_server: typing.Union[http.server.HTTPServer, None] = None 48 """ The HTTP server listening for connections. """ 49 50 self._thread: typing.Union[threading.Thread, None] = None 51 """ The thread running the HTTP server. """ 52 53 self._run_lock: threading.Lock = threading.Lock() 54 """ A lock that the server holds while running. """ 55 56 self.verbose: bool = verbose 57 """ Log more information. """ 58 59 self.raise_on_404: bool = raise_on_404 60 """ Raise an exception when no exchange is matched (instead of a 404 error). """ 61 62 self._exchanges: typing.Dict[str, typing.Dict[typing.Union[str, None], typing.Dict[str, typing.List[edq.util.net.HTTPExchange]]]] = {} 63 """ 64 The HTTP exchanges (requests+responses) that this server knows about. 65 Exchanges are stored in layers to help make errors for missing requests easier. 66 Exchanges are stored as: {url_path: {anchor: {method: [exchange, ...]}, ...}, ...}. 67 """ 68 69 if (match_options is None): 70 match_options = {} 71 72 if (default_match_options): 73 if ('headers_to_skip' not in match_options): 74 match_options['headers_to_skip'] = [] 75 76 match_options['headers_to_skip'] += edq.util.net.DEFAULT_EXCHANGE_IGNORE_HEADERS 77 78 self.match_options: typing.Dict[str, typing.Any] = match_options.copy() 79 """ Options to use when matching HTTP exchanges. """
The active port this server is using. If None, then a random port will be chosen when the server is started and this field will be populated.
81 def get_exchanges(self) -> typing.List[edq.util.net.HTTPExchange]: 82 """ 83 Get a shallow list of all the exchanges in this server. 84 Ordering is not guaranteed. 85 """ 86 87 exchanges = [] 88 89 for url_exchanges in self._exchanges.values(): 90 for anchor_exchanges in url_exchanges.values(): 91 for method_exchanges in anchor_exchanges.values(): 92 exchanges += method_exchanges 93 94 return exchanges
Get a shallow list of all the exchanges in this server. Ordering is not guaranteed.
96 def start(self) -> None: 97 """ Start this server in a thread and return the port. """ 98 99 class NestedHTTPHandler(_TestHTTPHandler): 100 """ An HTTP handler as a nested class to bind this server object to the handler. """ 101 102 _server = self 103 _verbose = self.verbose 104 _raise_on_404 = self.raise_on_404 105 _missing_request_func = self.missing_request 106 107 if (self.port is None): 108 self.port = edq.util.net.find_open_port() 109 110 self._http_server = http.server.HTTPServer(('', self.port), NestedHTTPHandler) 111 112 if (self.verbose): 113 logging.info("Starting test server on port %d.", self.port) 114 115 # Use a barrier to ensure that the server thread has started. 116 server_startup_barrier = threading.Barrier(2) 117 118 def _run_server(server: 'HTTPTestServer', server_startup_barrier: threading.Barrier) -> None: 119 server_startup_barrier.wait() 120 121 if (server._http_server is None): 122 raise ValueError('Server was not initialized.') 123 124 # Run the server within the run lock context. 125 with server._run_lock: 126 server._http_server.serve_forever(poll_interval = 0.01) 127 server._http_server.server_close() 128 129 if (self.verbose): 130 logging.info("Stopping test server.") 131 132 self._thread = threading.Thread(target = _run_server, args = (self, server_startup_barrier)) 133 self._thread.start() 134 135 # Wait for the server to startup. 136 server_startup_barrier.wait() 137 time.sleep(SERVER_THREAD_START_WAIT_SEC)
Start this server in a thread and return the port.
139 def wait_for_completion(self) -> None: 140 """ 141 Block until the server is not running. 142 If called while the server is not running, this will return immediately. 143 This function will handle keyboard interrupts (Ctrl-C). 144 """ 145 146 try: 147 with self._run_lock: 148 pass 149 except KeyboardInterrupt: 150 self.stop() 151 self.wait_for_completion()
Block until the server is not running. If called while the server is not running, this will return immediately. This function will handle keyboard interrupts (Ctrl-C).
153 def start_and_wait(self) -> None: 154 """ Start the server and block until it is done. """ 155 156 self.start() 157 self.wait_for_completion()
Start the server and block until it is done.
159 def stop(self) -> None: 160 """ Stop this server. """ 161 162 self.port = None 163 164 if (self._http_server is not None): 165 self._http_server.shutdown() 166 self._http_server = None 167 168 if (self._thread is not None): 169 if (self._thread.is_alive()): 170 self._thread.join(SERVER_THREAD_REAP_WAIT_SEC) 171 172 self._thread = None
Stop this server.
174 def missing_request(self, query: edq.util.net.HTTPExchange) -> typing.Union[edq.util.net.HTTPExchange, None]: 175 """ 176 Provide the server (specifically, child classes) one last chance to resolve an incoming HTTP request 177 before the server raises an exception. 178 Usually exchanges are loaded from disk, but technically a server can resolve all requests with this method. 179 180 Exchanges returned from this method are not cached/saved. 181 """ 182 183 return None
Provide the server (specifically, child classes) one last chance to resolve an incoming HTTP request before the server raises an exception. Usually exchanges are loaded from disk, but technically a server can resolve all requests with this method.
Exchanges returned from this method are not cached/saved.
185 def modify_exchanges(self, exchanges: typing.List[edq.util.net.HTTPExchange]) -> typing.List[edq.util.net.HTTPExchange]: 186 """ 187 Modify any exchanges before they are saved into this server's cache. 188 The returned exchanges will be saved in this server's cache. 189 190 This method may be called multiple times with different collections of exchanges. 191 """ 192 193 return exchanges
Modify any exchanges before they are saved into this server's cache. The returned exchanges will be saved in this server's cache.
This method may be called multiple times with different collections of exchanges.
195 def lookup_exchange(self, 196 query: edq.util.net.HTTPExchange, 197 match_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 198 ) -> typing.Tuple[typing.Union[edq.util.net.HTTPExchange, None], typing.Union[str, None]]: 199 """ 200 Lookup the query exchange to see if it exists in this server. 201 If a match exists, the matching exchange (likely a full version of the query) will be returned along with None. 202 If a match does not exist, a None will be returned along with a message indicating how the query missed 203 (e.g., the URL was matched, but the method was not). 204 """ 205 206 if (match_options is None): 207 match_options = {} 208 209 hint_display = query.url_path 210 target: typing.Any = self._exchanges 211 212 if (query.url_path not in target): 213 return None, f"Could not find matching URL path for '{hint_display}'." 214 215 hint_display = query.url_path 216 if (query.url_anchor is not None): 217 hint_display = f"{query.url_path}#{query.url_anchor}" 218 219 target = target[query.url_path] 220 221 if (query.url_anchor not in target): 222 return None, f"Found URL path, but could not find matching anchor for '{hint_display}'." 223 224 hint_display = f"{hint_display} ({query.method})" 225 target = target[query.url_anchor] 226 227 if (query.method not in target): 228 return None, f"Found URL, but could not find matching method for '{hint_display}'." 229 230 params = list(sorted(query.parameters.keys())) 231 hint_display = f"{hint_display}, (param keys = {params})" 232 target = target[query.method] 233 234 full_match_options = self.match_options.copy() 235 full_match_options.update(match_options) 236 237 hints = [] 238 matches = [] 239 240 for (i, exchange) in enumerate(target): 241 match, hint = exchange.match(query, **full_match_options) 242 if (match): 243 matches.append(exchange) 244 continue 245 246 # Collect hints for non-matches. 247 label = exchange.source_path 248 if (label is None): 249 label = str(i) 250 251 hints.append(f"{label}: {hint}") 252 253 if (len(matches) == 1): 254 # Found exactly one match. 255 return matches[0], None 256 257 if (len(matches) > 1): 258 # Found multiple matches. 259 match_paths = list(sorted([match.source_path for match in matches])) 260 return None, f"Found multiple matching exchanges for '{hint_display}': {match_paths}." 261 262 # Found no matches. 263 return None, f"Found matching URL and method, but could not find matching headers/params for '{hint_display}' (non-matching hints: {hints})."
Lookup the query exchange to see if it exists in this server. If a match exists, the matching exchange (likely a full version of the query) will be returned along with None. If a match does not exist, a None will be returned along with a message indicating how the query missed (e.g., the URL was matched, but the method was not).
265 def load_exchange(self, exchange: edq.util.net.HTTPExchange) -> None: 266 """ Load an exchange into this server. """ 267 268 if (exchange is None): 269 raise ValueError("Cannot load a None exchange.") 270 271 target: typing.Any = self._exchanges 272 if (exchange.url_path not in target): 273 target[exchange.url_path] = {} 274 275 target = target[exchange.url_path] 276 if (exchange.url_anchor not in target): 277 target[exchange.url_anchor] = {} 278 279 target = target[exchange.url_anchor] 280 if (exchange.method not in target): 281 target[exchange.method] = [] 282 283 target = target[exchange.method] 284 target.append(exchange)
Load an exchange into this server.
286 def load_exchange_file(self, path: str) -> None: 287 """ 288 Load an exchange from a file. 289 This will also handle setting the exchanges source path and resolving the exchange's paths. 290 """ 291 292 self.load_exchange(edq.util.net.HTTPExchange.from_path(path))
Load an exchange from a file. This will also handle setting the exchanges source path and resolving the exchange's paths.
294 def load_exchanges_dir(self, base_dir: str, extension: str = edq.util.net.DEFAULT_HTTP_EXCHANGE_EXTENSION) -> None: 295 """ Load all exchanges found (recursively) within a directory. """ 296 297 paths = list(sorted(glob.glob(os.path.join(base_dir, "**", f"*{extension}"), recursive = True))) 298 for path in paths: 299 self.load_exchange_file(path)
Load all exchanges found (recursively) within a directory.
301@typing.runtime_checkable 302class MissingRequestFunction(typing.Protocol): 303 """ 304 A function that can be used to create an exchange when none was found. 305 This is often the last resort when a test server cannot find an exchange matching a request. 306 """ 307 308 def __call__(self, 309 query: edq.util.net.HTTPExchange, 310 ) -> typing.Union[edq.util.net.HTTPExchange, None]: 311 """ 312 Create an exchange for the given query or return None. 313 """
A function that can be used to create an exchange when none was found. This is often the last resort when a test server cannot find an exchange matching a request.
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)
428class HTTPServerTest(edq.testing.unittest.BaseTest): 429 """ 430 A unit test class that requires a testing HTTP server to be running. 431 """ 432 433 server_key: str = '' 434 """ 435 A key to indicate which test server this test class is using. 436 By default all test classes share the same server, 437 but child classes can set this if they want to control who is using the same server. 438 If `tear_down_server` is true, then the relevant server will be stopped (and removed) on a call to tearDownClass(), 439 which happens after a test class is complete. 440 """ 441 442 tear_down_server: bool = True 443 """ 444 Tear down the relevant test server in tearDownClass(). 445 If set to false then the server will never get torn down, 446 but can be shared between child test classes. 447 """ 448 449 skip_test_exchanges_base: bool = False 450 """ Skip test_exchanges_base. """ 451 452 _servers: typing.Dict[str, HTTPTestServer] = {} 453 """ The active test servers. """ 454 455 _complete_exchange_tests: typing.Set[str] = set() 456 """ 457 Keep track of the servers (by key) that have run their test_exchanges_base. 458 This test should only be run once per server. 459 """ 460 461 _child_class_setup_called: bool = False 462 """ Keep track if the child class setup was called. """ 463 464 @classmethod 465 def setUpClass(cls) -> None: 466 if (not cls._child_class_setup_called): 467 cls.child_class_setup() 468 cls._child_class_setup_called = True 469 470 if (cls.server_key in cls._servers): 471 return 472 473 server = HTTPTestServer() 474 cls._servers[cls.server_key] = server 475 476 cls.setup_server(server) 477 server.start() 478 cls.post_start_server(server) 479 480 @classmethod 481 def tearDownClass(cls) -> None: 482 if (cls.server_key not in cls._servers): 483 return 484 485 server = cls.get_server() 486 487 if (cls.tear_down_server): 488 server.stop() 489 del cls._servers[cls.server_key] 490 cls._complete_exchange_tests.discard(cls.server_key) 491 492 @classmethod 493 def suite_cleanup(cls) -> None: 494 """ Cleanup all test servers. """ 495 496 for server in cls._servers.values(): 497 server.stop() 498 499 cls._servers.clear() 500 501 @classmethod 502 def get_server(cls) -> HTTPTestServer: 503 """ Get the current HTTP server or raise if there is no server. """ 504 505 server = cls._servers.get(cls.server_key, None) 506 if (server is None): 507 raise ValueError("Server has not been initialized.") 508 509 return server 510 511 @classmethod 512 def child_class_setup(cls) -> None: 513 """ This function is the recommended time for child classes to set any configuration. """ 514 515 @classmethod 516 def setup_server(cls, server: HTTPTestServer) -> None: 517 """ An opportunity for child classes to configure the test server before starting it. """ 518 519 @classmethod 520 def post_start_server(cls, server: HTTPTestServer) -> None: 521 """ An opportunity for child classes to work with the server after it has been started, but before any tests. """ 522 523 @classmethod 524 def get_server_url(cls) -> str: 525 """ Get the URL for this test's test server. """ 526 527 server = cls.get_server() 528 529 if (server.port is None): 530 raise ValueError("Test server port has not been set.") 531 532 return f"http://127.0.0.1:{server.port}" 533 534 def assert_exchange(self, request: edq.util.net.HTTPExchange, response: edq.util.net.HTTPExchange, 535 base_url: typing.Union[str, None] = None, 536 ) -> requests.Response: 537 """ 538 Assert that the result of making the provided request matches the provided response. 539 The same HTTPExchange may be supplied for both the request and response. 540 By default, the server's URL will be used as the base URL. 541 The full response will be returned (if no assertion is raised). 542 """ 543 544 server = self.get_server() 545 546 if (base_url is None): 547 base_url = self.get_server_url() 548 549 full_response, body = request.make_request(base_url, raise_for_status = True, **server.match_options) 550 551 match, hint = response.match_response(full_response, override_body = body, **server.match_options) 552 if (not match): 553 raise AssertionError(f"Exchange does not match: '{hint}'.") 554 555 return full_response 556 557 def test_exchanges_base(self) -> None: 558 """ Test making a request with each of the loaded exchanges. """ 559 560 # Check if this test has already been run for this server. 561 if (self.server_key in self._complete_exchange_tests): 562 # Don't skip the test (which will show up in the test output). 563 # Instead, just return. 564 return 565 566 if (self.skip_test_exchanges_base): 567 self.skipTest('test_exchanges_base has been manually skipped.') 568 569 self._complete_exchange_tests.add(self.server_key) 570 571 server = self.get_server() 572 573 for (i, exchange) in enumerate(server.get_exchanges()): 574 base_name = exchange.get_url() 575 if (exchange.source_path is not None): 576 base_name = os.path.splitext(os.path.basename(exchange.source_path))[0] 577 578 with self.subTest(msg = f"Case {i} ({base_name}):"): 579 self.assert_exchange(exchange, exchange)
A unit test class that requires a testing HTTP server to be running.
A key to indicate which test server this test class is using.
By default all test classes share the same server,
but child classes can set this if they want to control who is using the same server.
If tear_down_server is true, then the relevant server will be stopped (and removed) on a call to tearDownClass(),
which happens after a test class is complete.
Tear down the relevant test server in tearDownClass(). If set to false then the server will never get torn down, but can be shared between child test classes.
464 @classmethod 465 def setUpClass(cls) -> None: 466 if (not cls._child_class_setup_called): 467 cls.child_class_setup() 468 cls._child_class_setup_called = True 469 470 if (cls.server_key in cls._servers): 471 return 472 473 server = HTTPTestServer() 474 cls._servers[cls.server_key] = server 475 476 cls.setup_server(server) 477 server.start() 478 cls.post_start_server(server)
Hook method for setting up class fixture before running tests in the class.
480 @classmethod 481 def tearDownClass(cls) -> None: 482 if (cls.server_key not in cls._servers): 483 return 484 485 server = cls.get_server() 486 487 if (cls.tear_down_server): 488 server.stop() 489 del cls._servers[cls.server_key] 490 cls._complete_exchange_tests.discard(cls.server_key)
Hook method for deconstructing the class fixture after running all tests in the class.
492 @classmethod 493 def suite_cleanup(cls) -> None: 494 """ Cleanup all test servers. """ 495 496 for server in cls._servers.values(): 497 server.stop() 498 499 cls._servers.clear()
Cleanup all test servers.
501 @classmethod 502 def get_server(cls) -> HTTPTestServer: 503 """ Get the current HTTP server or raise if there is no server. """ 504 505 server = cls._servers.get(cls.server_key, None) 506 if (server is None): 507 raise ValueError("Server has not been initialized.") 508 509 return server
Get the current HTTP server or raise if there is no server.
511 @classmethod 512 def child_class_setup(cls) -> None: 513 """ This function is the recommended time for child classes to set any configuration. """
This function is the recommended time for child classes to set any configuration.
515 @classmethod 516 def setup_server(cls, server: HTTPTestServer) -> None: 517 """ An opportunity for child classes to configure the test server before starting it. """
An opportunity for child classes to configure the test server before starting it.
519 @classmethod 520 def post_start_server(cls, server: HTTPTestServer) -> None: 521 """ An opportunity for child classes to work with the server after it has been started, but before any tests. """
An opportunity for child classes to work with the server after it has been started, but before any tests.
523 @classmethod 524 def get_server_url(cls) -> str: 525 """ Get the URL for this test's test server. """ 526 527 server = cls.get_server() 528 529 if (server.port is None): 530 raise ValueError("Test server port has not been set.") 531 532 return f"http://127.0.0.1:{server.port}"
Get the URL for this test's test server.
534 def assert_exchange(self, request: edq.util.net.HTTPExchange, response: edq.util.net.HTTPExchange, 535 base_url: typing.Union[str, None] = None, 536 ) -> requests.Response: 537 """ 538 Assert that the result of making the provided request matches the provided response. 539 The same HTTPExchange may be supplied for both the request and response. 540 By default, the server's URL will be used as the base URL. 541 The full response will be returned (if no assertion is raised). 542 """ 543 544 server = self.get_server() 545 546 if (base_url is None): 547 base_url = self.get_server_url() 548 549 full_response, body = request.make_request(base_url, raise_for_status = True, **server.match_options) 550 551 match, hint = response.match_response(full_response, override_body = body, **server.match_options) 552 if (not match): 553 raise AssertionError(f"Exchange does not match: '{hint}'.") 554 555 return full_response
Assert that the result of making the provided request matches the provided response. The same HTTPExchange may be supplied for both the request and response. By default, the server's URL will be used as the base URL. The full response will be returned (if no assertion is raised).
557 def test_exchanges_base(self) -> None: 558 """ Test making a request with each of the loaded exchanges. """ 559 560 # Check if this test has already been run for this server. 561 if (self.server_key in self._complete_exchange_tests): 562 # Don't skip the test (which will show up in the test output). 563 # Instead, just return. 564 return 565 566 if (self.skip_test_exchanges_base): 567 self.skipTest('test_exchanges_base has been manually skipped.') 568 569 self._complete_exchange_tests.add(self.server_key) 570 571 server = self.get_server() 572 573 for (i, exchange) in enumerate(server.get_exchanges()): 574 base_name = exchange.get_url() 575 if (exchange.source_path is not None): 576 base_name = os.path.splitext(os.path.basename(exchange.source_path))[0] 577 578 with self.subTest(msg = f"Case {i} ({base_name}):"): 579 self.assert_exchange(exchange, exchange)
Test making a request with each of the loaded exchanges.