edq.net.exchangeserver
1import glob 2import http.server 3import logging 4import os 5import threading 6import time 7import typing 8 9import edq.net.exchange 10import edq.util.dirent 11import edq.util.serial 12 13_logger = logging.getLogger(__name__) 14 15SERVER_THREAD_START_WAIT_SEC: float = 0.02 16SERVER_THREAD_REAP_WAIT_SEC: float = 0.15 17 18class HTTPExchangeServer(): 19 """ 20 An HTTP server meant for testing. 21 This server is generally meant to already know about all the requests that will be made to it, 22 and all the responses it should make in reaction to those respective requests. 23 This allows the server to respond very quickly, and makes it ideal for testing. 24 This makes it easy to mock external services for testing. 25 26 If a request is not found in the predefined requests, 27 then missing_request() will be called. 28 If a response is still not available (indicated by a None return from missing_request()), 29 then an error will be raised. 30 """ 31 32 def __init__(self, 33 port: typing.Union[int, None] = None, 34 match_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 35 default_match_options: bool = True, 36 verbose: bool = False, 37 raise_on_404: bool = False, 38 **kwargs: typing.Any) -> None: 39 self.port: typing.Union[int, None] = port 40 """ 41 The active port this server is using. 42 If None, then a random port will be chosen when the server is started and this field will be populated. 43 """ 44 45 self._http_server: typing.Union[http.server.HTTPServer, None] = None 46 """ The HTTP server listening for connections. """ 47 48 self._thread: typing.Union[threading.Thread, None] = None 49 """ The thread running the HTTP server. """ 50 51 self._run_lock: threading.Lock = threading.Lock() 52 """ A lock that the server holds while running. """ 53 54 self.verbose: bool = verbose 55 """ Log more information. """ 56 57 self.raise_on_404: bool = raise_on_404 58 """ Raise an exception when no exchange is matched (instead of a 404 error). """ 59 60 self._exchanges: typing.Dict[str, typing.Dict[typing.Union[str, None], typing.Dict[str, typing.List[edq.net.exchange.HTTPExchange]]]] = {} 61 """ 62 The HTTP exchanges (requests+responses) that this server knows about. 63 Exchanges are stored in layers to help make errors for missing requests easier. 64 Exchanges are stored as: {url_path: {anchor: {method: [exchange, ...]}, ...}, ...}. 65 """ 66 67 if (match_options is None): 68 match_options = {} 69 70 if (default_match_options): 71 if ('headers_to_skip' not in match_options): 72 match_options['headers_to_skip'] = [] 73 74 match_options['headers_to_skip'] += edq.net.settings.get_exchanges_ignore_headers() 75 76 self.match_options: typing.Dict[str, typing.Any] = match_options.copy() 77 """ Options to use when matching HTTP exchanges. """ 78 79 def get_exchanges(self) -> typing.List[edq.net.exchange.HTTPExchange]: 80 """ 81 Get a shallow list of all the exchanges in this server. 82 Ordering is not guaranteed. 83 """ 84 85 exchanges = [] 86 87 for url_exchanges in self._exchanges.values(): 88 for anchor_exchanges in url_exchanges.values(): 89 for method_exchanges in anchor_exchanges.values(): 90 exchanges += method_exchanges 91 92 return exchanges 93 94 def start(self) -> None: 95 """ Start this server in a thread and set the active port. """ 96 97 class NestedHTTPHandler(_TestHTTPHandler): 98 """ An HTTP handler as a nested class to bind this server object to the handler. """ 99 100 _server = self 101 _verbose = self.verbose 102 _raise_on_404 = self.raise_on_404 103 _missing_request_func = self.missing_request 104 105 if (self.port is None): 106 self.port = edq.net.util.find_open_port() 107 108 self._http_server = http.server.HTTPServer(('', self.port), NestedHTTPHandler) 109 110 if (self.verbose): 111 _logger.info("Starting test server on port %d.", self.port) 112 113 # Use a barrier to ensure that the server thread has started. 114 server_startup_barrier = threading.Barrier(2) 115 116 def _run_server(server: 'HTTPExchangeServer', server_startup_barrier: threading.Barrier) -> None: 117 server_startup_barrier.wait() 118 119 if (server._http_server is None): 120 raise ValueError('Server was not initialized.') 121 122 # Run the server within the run lock context. 123 with server._run_lock: 124 server._http_server.serve_forever(poll_interval = 0.01) 125 server._http_server.server_close() 126 127 if (self.verbose): 128 _logger.info("Stopping test server.") 129 130 self._thread = threading.Thread(target = _run_server, args = (self, server_startup_barrier)) 131 self._thread.start() 132 133 # Wait for the server to startup. 134 server_startup_barrier.wait() 135 time.sleep(SERVER_THREAD_START_WAIT_SEC) 136 137 def wait_for_completion(self) -> None: 138 """ 139 Block until the server is not running. 140 If called while the server is not running, this will return immediately. 141 This function will handle keyboard interrupts (Ctrl-C). 142 """ 143 144 try: 145 with self._run_lock: 146 pass 147 except KeyboardInterrupt: 148 self.stop() 149 self.wait_for_completion() 150 151 def start_and_wait(self) -> None: 152 """ Start the server and block until it is done. """ 153 154 self.start() 155 self.wait_for_completion() 156 157 def stop(self) -> None: 158 """ Stop this server. """ 159 160 self.port = None 161 162 if (self._http_server is not None): 163 self._http_server.shutdown() 164 self._http_server = None 165 166 if (self._thread is not None): 167 if (self._thread.is_alive()): 168 self._thread.join(SERVER_THREAD_REAP_WAIT_SEC) 169 170 self._thread = None 171 172 def missing_request(self, query: edq.net.exchange.HTTPExchange) -> typing.Union[edq.net.exchange.HTTPExchange, None]: 173 """ 174 Provide the server (specifically, child classes) one last chance to resolve an incoming HTTP request 175 before the server raises an exception. 176 Usually exchanges are loaded from disk, but technically a server can resolve all requests with this method. 177 178 Exchanges returned from this method are not cached/saved. 179 """ 180 181 return None 182 183 def modify_exchanges(self, exchanges: typing.List[edq.net.exchange.HTTPExchange]) -> typing.List[edq.net.exchange.HTTPExchange]: 184 """ 185 Modify any exchanges before they are saved into this server's cache. 186 The returned exchanges will be saved in this server's cache. 187 188 This method may be called multiple times with different collections of exchanges. 189 """ 190 191 return exchanges 192 193 def lookup_exchange(self, 194 query: edq.net.exchange.HTTPExchange, 195 match_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 196 ) -> typing.Tuple[typing.Union[edq.net.exchange.HTTPExchange, None], typing.Union[str, None]]: 197 """ 198 Lookup the query exchange to see if it exists in this server. 199 If a match exists, the matching exchange (likely a full version of the query) will be returned along with None. 200 If a match does not exist, a None will be returned along with a message indicating how the query missed 201 (e.g., the URL was matched, but the method was not). 202 """ 203 204 if (match_options is None): 205 match_options = {} 206 207 hint_display = query.url_path 208 target: typing.Any = self._exchanges 209 210 if (query.url_path not in target): 211 return None, f"Could not find matching URL path for '{hint_display}'." 212 213 hint_display = query.url_path 214 if (query.url_anchor is not None): 215 hint_display = f"{query.url_path}#{query.url_anchor}" 216 217 target = target[query.url_path] 218 219 if (query.url_anchor not in target): 220 return None, f"Found URL path, but could not find matching anchor for '{hint_display}'." 221 222 hint_display = f"{hint_display} ({query.method})" 223 target = target[query.url_anchor] 224 225 if (query.method not in target): 226 return None, f"Found URL, but could not find matching method for '{hint_display}'." 227 228 params = list(sorted(query.parameters.keys())) 229 hint_display = f"{hint_display}, (param keys = {params})" 230 target = target[query.method] 231 232 full_match_options = self.match_options.copy() 233 full_match_options.update(match_options) 234 235 hints = [] 236 matches = [] 237 238 for (i, exchange) in enumerate(target): 239 match, hint = exchange.match(query, **full_match_options) 240 if (match): 241 matches.append(exchange) 242 continue 243 244 # Collect hints for non-matches. 245 label = exchange.source_path 246 if (label is None): 247 label = str(i) 248 249 hints.append(f"{label}: {hint}") 250 251 if (len(matches) == 1): 252 # Found exactly one match. 253 return matches[0], None 254 255 if (len(matches) > 1): 256 # Found multiple matches. 257 match_paths = list(sorted([match.source_path for match in matches])) 258 return None, f"Found multiple matching exchanges for '{hint_display}': {match_paths}." 259 260 # Found no matches. 261 return None, f"Found matching URL and method, but could not find matching headers/params for '{hint_display}' (non-matching hints: {hints})." 262 263 def load_exchange(self, exchange: edq.net.exchange.HTTPExchange) -> None: 264 """ Load an exchange into this server. """ 265 266 if (exchange is None): 267 raise ValueError("Cannot load a None exchange.") 268 269 target: typing.Any = self._exchanges 270 if (exchange.url_path not in target): 271 target[exchange.url_path] = {} 272 273 target = target[exchange.url_path] 274 if (exchange.url_anchor not in target): 275 target[exchange.url_anchor] = {} 276 277 target = target[exchange.url_anchor] 278 if (exchange.method not in target): 279 target[exchange.method] = [] 280 281 target = target[exchange.method] 282 target.append(exchange) 283 284 def load_exchange_file(self, 285 path: str, 286 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 287 finalize_func: typing.Union[edq.net.exchange.HTTPExchangeFinalizeFunc, None] = None, 288 ) -> None: 289 """ 290 Load an exchange from a file. 291 This will also handle setting the exchanges source path and resolving the exchange's paths. 292 """ 293 294 exchange = edq.net.exchange.HTTPExchange.from_path(path, context = context) 295 296 if (finalize_func is not None): 297 exchange = finalize_func(exchange) 298 299 self.load_exchange(exchange) 300 301 def load_exchanges_dir(self, 302 base_dir: str, 303 extension: str = edq.net.exchange.DEFAULT_HTTP_EXCHANGE_EXTENSION, 304 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 305 finalize_func: typing.Union[edq.net.exchange.HTTPExchangeFinalizeFunc, None] = None, 306 ) -> None: 307 """ Load all exchanges found (recursively) within a directory. """ 308 309 paths = list(sorted(glob.glob(os.path.join(base_dir, "**", f"*{extension}"), recursive = True))) 310 for path in paths: 311 self.load_exchange_file(path, context = context, finalize_func = finalize_func) 312 313@typing.runtime_checkable 314class MissingRequestFunction(typing.Protocol): 315 """ 316 A function that can be used to create an exchange when none was found. 317 This is often the last resort when a test server cannot find an exchange matching a request. 318 """ 319 320 def __call__(self, 321 query: edq.net.exchange.HTTPExchange, 322 ) -> typing.Union[edq.net.exchange.HTTPExchange, None]: 323 """ 324 Create an exchange for the given query or return None. 325 """ 326 327class _TestHTTPHandler(http.server.BaseHTTPRequestHandler): 328 _server: typing.Union[HTTPExchangeServer, None] = None 329 """ The test server this handler is being used for. """ 330 331 _verbose: bool = False 332 """ Log more interactions. """ 333 334 _raise_on_404: bool = True 335 """ Raise an exception when no exchange is matched (instead of a 404 error). """ 336 337 _missing_request_func: typing.Union[MissingRequestFunction, None] = None 338 """ A fallback to get an exchange before resulting in a 404. """ 339 340 # Quiet logs. 341 def log_message(self, format: str, *args: typing.Any) -> None: # pylint: disable=redefined-builtin 342 pass 343 344 def do_DELETE(self) -> None: # pylint: disable=invalid-name 345 """ A handler for DELETE requests. """ 346 347 self._do_request('DELETE') 348 349 def do_GET(self) -> None: # pylint: disable=invalid-name 350 """ A handler for GET requests. """ 351 352 self._do_request('GET') 353 354 def do_HEAD(self) -> None: # pylint: disable=invalid-name 355 """ A handler for HEAD requests. """ 356 357 self._do_request('HEAD') 358 359 def do_OPTIONS(self) -> None: # pylint: disable=invalid-name 360 """ A handler for OPTIONS requests. """ 361 362 self._do_request('OPTIONS') 363 364 def do_PATCH(self) -> None: # pylint: disable=invalid-name 365 """ A handler for PATCH requests. """ 366 367 self._do_request('PATCH') 368 369 def do_POST(self) -> None: # pylint: disable=invalid-name 370 """ A handler for POST requests. """ 371 372 self._do_request('POST') 373 374 def do_PUT(self) -> None: # pylint: disable=invalid-name 375 """ A handler for PUT requests. """ 376 377 self._do_request('PUT') 378 379 def _do_request(self, method: str) -> None: 380 """ A common handler for multiple types of requests. """ 381 382 if (self._server is None): 383 raise ValueError("Server has not been initialized.") 384 385 if (self._verbose): 386 _logger.debug("Incoming %s request: '%s'.", method, self.path) 387 388 # Parse data from the request url and body. 389 request_data, request_files = edq.net.util.parse_request_data(self.path, self.headers, self.rfile) 390 391 # Construct file info objects from the raw files. 392 files = [edq.net.exchange.FileInfo(name = name, content = content) for (name, content) in request_files.items()] 393 394 exchange, hint = self._get_exchange(method, parameters = request_data, files = files) # type: ignore[arg-type] 395 396 if (exchange is None): 397 code = http.HTTPStatus.NOT_FOUND.value 398 headers = {} 399 payload = hint 400 else: 401 code = exchange.response_code 402 headers = exchange.response_headers 403 payload = exchange.response_body 404 405 if (payload is None): 406 payload = '' 407 408 self.send_response(code) 409 for (key, value) in headers.items(): 410 self.send_header(key, value) 411 self.end_headers() 412 413 self.wfile.write(payload.encode(edq.util.dirent.DEFAULT_ENCODING)) 414 415 def _get_exchange(self, method: str, 416 parameters: typing.Union[typing.Dict[str, typing.Any], None] = None, 417 files: typing.Union[typing.List[typing.Union[edq.net.exchange.FileInfo, typing.Dict[str, str]]], None] = None, 418 ) -> typing.Tuple[typing.Union[edq.net.exchange.HTTPExchange, None], typing.Union[str, None]]: 419 """ Get the matching exchange or raise an error. """ 420 421 if (self._server is None): 422 raise ValueError("Server has not been initialized.") 423 424 query = edq.net.exchange.HTTPExchange(method = method, 425 url = self.path, 426 url_anchor = self.headers.get(edq.net.settings.ANCHOR_HEADER_KEY, None), 427 headers = self.headers, # type: ignore[arg-type] 428 parameters = parameters, files = files) 429 430 exchange, hint = self._server.lookup_exchange(query) 431 432 if ((exchange is None) and (self._missing_request_func is not None)): 433 exchange = self._missing_request_func(query) # pylint: disable=not-callable 434 435 if ((exchange is None) and self._raise_on_404): 436 raise ValueError(f"Failed to lookup exchange: '{hint}'.") 437 438 return exchange, hint
19class HTTPExchangeServer(): 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.net.exchange.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.net.settings.get_exchanges_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.net.exchange.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 set the active 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.net.util.find_open_port() 108 109 self._http_server = http.server.HTTPServer(('', self.port), NestedHTTPHandler) 110 111 if (self.verbose): 112 _logger.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: 'HTTPExchangeServer', 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 _logger.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.net.exchange.HTTPExchange) -> typing.Union[edq.net.exchange.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.net.exchange.HTTPExchange]) -> typing.List[edq.net.exchange.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.net.exchange.HTTPExchange, 196 match_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 197 ) -> typing.Tuple[typing.Union[edq.net.exchange.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.net.exchange.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, 286 path: str, 287 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 288 finalize_func: typing.Union[edq.net.exchange.HTTPExchangeFinalizeFunc, None] = None, 289 ) -> None: 290 """ 291 Load an exchange from a file. 292 This will also handle setting the exchanges source path and resolving the exchange's paths. 293 """ 294 295 exchange = edq.net.exchange.HTTPExchange.from_path(path, context = context) 296 297 if (finalize_func is not None): 298 exchange = finalize_func(exchange) 299 300 self.load_exchange(exchange) 301 302 def load_exchanges_dir(self, 303 base_dir: str, 304 extension: str = edq.net.exchange.DEFAULT_HTTP_EXCHANGE_EXTENSION, 305 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 306 finalize_func: typing.Union[edq.net.exchange.HTTPExchangeFinalizeFunc, None] = None, 307 ) -> None: 308 """ Load all exchanges found (recursively) within a directory. """ 309 310 paths = list(sorted(glob.glob(os.path.join(base_dir, "**", f"*{extension}"), recursive = True))) 311 for path in paths: 312 self.load_exchange_file(path, context = context, finalize_func = finalize_func)
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.
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.net.exchange.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.net.settings.get_exchanges_ignore_headers() 76 77 self.match_options: typing.Dict[str, typing.Any] = match_options.copy() 78 """ 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.
80 def get_exchanges(self) -> typing.List[edq.net.exchange.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
Get a shallow list of all the exchanges in this server. Ordering is not guaranteed.
95 def start(self) -> None: 96 """ Start this server in a thread and set the active 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.net.util.find_open_port() 108 109 self._http_server = http.server.HTTPServer(('', self.port), NestedHTTPHandler) 110 111 if (self.verbose): 112 _logger.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: 'HTTPExchangeServer', 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 _logger.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)
Start this server in a thread and set the active port.
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()
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).
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()
Start the server and block until it is done.
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
Stop this server.
173 def missing_request(self, query: edq.net.exchange.HTTPExchange) -> typing.Union[edq.net.exchange.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
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.
184 def modify_exchanges(self, exchanges: typing.List[edq.net.exchange.HTTPExchange]) -> typing.List[edq.net.exchange.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
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.
194 def lookup_exchange(self, 195 query: edq.net.exchange.HTTPExchange, 196 match_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 197 ) -> typing.Tuple[typing.Union[edq.net.exchange.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})."
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).
264 def load_exchange(self, exchange: edq.net.exchange.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)
Load an exchange into this server.
285 def load_exchange_file(self, 286 path: str, 287 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 288 finalize_func: typing.Union[edq.net.exchange.HTTPExchangeFinalizeFunc, None] = None, 289 ) -> None: 290 """ 291 Load an exchange from a file. 292 This will also handle setting the exchanges source path and resolving the exchange's paths. 293 """ 294 295 exchange = edq.net.exchange.HTTPExchange.from_path(path, context = context) 296 297 if (finalize_func is not None): 298 exchange = finalize_func(exchange) 299 300 self.load_exchange(exchange)
Load an exchange from a file. This will also handle setting the exchanges source path and resolving the exchange's paths.
302 def load_exchanges_dir(self, 303 base_dir: str, 304 extension: str = edq.net.exchange.DEFAULT_HTTP_EXCHANGE_EXTENSION, 305 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 306 finalize_func: typing.Union[edq.net.exchange.HTTPExchangeFinalizeFunc, None] = None, 307 ) -> None: 308 """ Load all exchanges found (recursively) within a directory. """ 309 310 paths = list(sorted(glob.glob(os.path.join(base_dir, "**", f"*{extension}"), recursive = True))) 311 for path in paths: 312 self.load_exchange_file(path, context = context, finalize_func = finalize_func)
Load all exchanges found (recursively) within a directory.
314@typing.runtime_checkable 315class MissingRequestFunction(typing.Protocol): 316 """ 317 A function that can be used to create an exchange when none was found. 318 This is often the last resort when a test server cannot find an exchange matching a request. 319 """ 320 321 def __call__(self, 322 query: edq.net.exchange.HTTPExchange, 323 ) -> typing.Union[edq.net.exchange.HTTPExchange, None]: 324 """ 325 Create an exchange for the given query or return None. 326 """
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)