edq-utils 0.0.4__py3-none-any.whl → 0.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of edq-utils might be problematic. Click here for more details.

Files changed (71) hide show
  1. edq/__init__.py +1 -1
  2. edq/cli/config/__init__.py +3 -0
  3. edq/cli/config/list.py +69 -0
  4. edq/cli/http/__init__.py +3 -0
  5. edq/cli/http/exchange-server.py +71 -0
  6. edq/cli/http/send-exchange.py +45 -0
  7. edq/cli/http/verify-exchanges.py +38 -0
  8. edq/cli/testing/__init__.py +3 -0
  9. edq/cli/testing/cli-test.py +12 -5
  10. edq/cli/version.py +2 -1
  11. edq/core/argparser.py +28 -3
  12. edq/core/config.py +268 -0
  13. edq/core/config_test.py +1038 -0
  14. edq/procedure/verify_exchanges.py +85 -0
  15. edq/testing/asserts.py +0 -1
  16. edq/testing/cli.py +107 -29
  17. edq/testing/cli_test.py +8 -1
  18. edq/testing/httpserver.py +553 -0
  19. edq/testing/httpserver_test.py +424 -0
  20. edq/testing/run.py +40 -10
  21. edq/testing/testdata/cli/data/configs/empty/edq-config.json +1 -0
  22. edq/testing/testdata/cli/data/configs/simple-1/edq-config.json +4 -0
  23. edq/testing/testdata/cli/data/configs/simple-2/edq-config.json +3 -0
  24. edq/testing/testdata/cli/data/configs/value-number/edq-config.json +3 -0
  25. edq/testing/testdata/cli/tests/config/list/config_list_base.txt +16 -0
  26. edq/testing/testdata/cli/tests/config/list/config_list_config_value_number.txt +10 -0
  27. edq/testing/testdata/cli/tests/config/list/config_list_ignore_config.txt +14 -0
  28. edq/testing/testdata/cli/tests/config/list/config_list_no_config.txt +8 -0
  29. edq/testing/testdata/cli/tests/config/list/config_list_show_origin.txt +13 -0
  30. edq/testing/testdata/cli/tests/config/list/config_list_skip_header.txt +10 -0
  31. edq/testing/testdata/cli/tests/platform_skip.txt +5 -0
  32. edq/testing/testdata/http/exchanges/simple.httpex.json +5 -0
  33. edq/testing/testdata/http/exchanges/simple_anchor.httpex.json +5 -0
  34. edq/testing/testdata/http/exchanges/simple_file.httpex.json +10 -0
  35. edq/testing/testdata/http/exchanges/simple_file_binary.httpex.json +10 -0
  36. edq/testing/testdata/http/exchanges/simple_file_get_params.httpex.json +14 -0
  37. edq/testing/testdata/http/exchanges/simple_file_multiple.httpex.json +13 -0
  38. edq/testing/testdata/http/exchanges/simple_file_name.httpex.json +11 -0
  39. edq/testing/testdata/http/exchanges/simple_file_post_multiple.httpex.json +13 -0
  40. edq/testing/testdata/http/exchanges/simple_file_post_params.httpex.json +14 -0
  41. edq/testing/testdata/http/exchanges/simple_headers.httpex.json +8 -0
  42. edq/testing/testdata/http/exchanges/simple_jsonresponse_dict.httpex.json +7 -0
  43. edq/testing/testdata/http/exchanges/simple_jsonresponse_list.httpex.json +9 -0
  44. edq/testing/testdata/http/exchanges/simple_params.httpex.json +9 -0
  45. edq/testing/testdata/http/exchanges/simple_post.httpex.json +5 -0
  46. edq/testing/testdata/http/exchanges/simple_post_params.httpex.json +9 -0
  47. edq/testing/testdata/http/exchanges/simple_post_urlparams.httpex.json +5 -0
  48. edq/testing/testdata/http/exchanges/simple_urlparams.httpex.json +5 -0
  49. edq/testing/testdata/http/exchanges/specialcase_listparams_explicit.httpex.json +8 -0
  50. edq/testing/testdata/http/exchanges/specialcase_listparams_url.httpex.json +5 -0
  51. edq/testing/testdata/http/files/tiny.png +0 -0
  52. edq/testing/unittest.py +26 -6
  53. edq/util/dirent.py +2 -0
  54. edq/util/dirent_test.py +43 -32
  55. edq/util/json.py +21 -4
  56. edq/util/net.py +894 -0
  57. edq_utils-0.0.6.dist-info/METADATA +156 -0
  58. edq_utils-0.0.6.dist-info/RECORD +78 -0
  59. edq/util/testdata/dirent-operations/dir_1/b.txt +0 -1
  60. edq/util/testdata/dirent-operations/dir_1/dir_2/c.txt +0 -1
  61. edq/util/testdata/dirent-operations/symlink_a.txt +0 -1
  62. edq/util/testdata/dirent-operations/symlink_dir_1/b.txt +0 -1
  63. edq/util/testdata/dirent-operations/symlink_dir_1/dir_2/c.txt +0 -1
  64. edq/util/testdata/dirent-operations/symlink_file_empty +0 -0
  65. edq_utils-0.0.4.dist-info/METADATA +0 -63
  66. edq_utils-0.0.4.dist-info/RECORD +0 -41
  67. /edq/{util/testdata/dirent-operations/file_empty → procedure/__init__.py} +0 -0
  68. /edq/{util/testdata/dirent-operations → testing/testdata/http/files}/a.txt +0 -0
  69. {edq_utils-0.0.4.dist-info → edq_utils-0.0.6.dist-info}/WHEEL +0 -0
  70. {edq_utils-0.0.4.dist-info → edq_utils-0.0.6.dist-info}/licenses/LICENSE +0 -0
  71. {edq_utils-0.0.4.dist-info → edq_utils-0.0.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,553 @@
1
+ import glob
2
+ import http.server
3
+ import logging
4
+ import os
5
+ import threading
6
+ import time
7
+ import typing
8
+
9
+ import requests
10
+
11
+ import edq.testing.unittest
12
+ import edq.util.dirent
13
+ import edq.util.json
14
+ import edq.util.net
15
+
16
+ SERVER_THREAD_START_WAIT_SEC: float = 0.02
17
+ SERVER_THREAD_REAP_WAIT_SEC: float = 0.15
18
+
19
+ class 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
301
+ class 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
+
314
+ class _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_POST(self) -> None: # pylint: disable=invalid-name
332
+ """ A handler for POST requests. """
333
+
334
+ self._do_request('POST')
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_request(self, method: str) -> None:
342
+ """ A common handler for multiple types of requests. """
343
+
344
+ if (self._server is None):
345
+ raise ValueError("Server has not been initialized.")
346
+
347
+ if (self._verbose):
348
+ logging.debug("Incoming %s request: '%s'.", method, self.path)
349
+
350
+ # Parse data from the request url and body.
351
+ request_data, request_files = edq.util.net.parse_request_data(self.path, self.headers, self.rfile)
352
+
353
+ # Construct file info objects from the raw files.
354
+ files = [edq.util.net.FileInfo(name = name, content = content) for (name, content) in request_files.items()]
355
+
356
+ exchange, hint = self._get_exchange(method, parameters = request_data, files = files) # type: ignore[arg-type]
357
+
358
+ if (exchange is None):
359
+ code = http.HTTPStatus.NOT_FOUND.value
360
+ headers = {}
361
+ payload = hint
362
+ else:
363
+ code = exchange.response_code
364
+ headers = exchange.response_headers
365
+ payload = exchange.response_body
366
+
367
+ if (payload is None):
368
+ payload = ''
369
+
370
+ self.send_response(code)
371
+ for (key, value) in headers.items():
372
+ self.send_header(key, value)
373
+ self.end_headers()
374
+
375
+ self.wfile.write(payload.encode(edq.util.dirent.DEFAULT_ENCODING))
376
+
377
+ def _get_exchange(self, method: str,
378
+ parameters: typing.Union[typing.Dict[str, typing.Any], None] = None,
379
+ files: typing.Union[typing.List[typing.Union[edq.util.net.FileInfo, typing.Dict[str, str]]], None] = None,
380
+ ) -> typing.Tuple[typing.Union[edq.util.net.HTTPExchange, None], typing.Union[str, None]]:
381
+ """ Get the matching exchange or raise an error. """
382
+
383
+ if (self._server is None):
384
+ raise ValueError("Server has not been initialized.")
385
+
386
+ query = edq.util.net.HTTPExchange(method = method,
387
+ url = self.path,
388
+ url_anchor = self.headers.get(edq.util.net.ANCHOR_HEADER_KEY, None),
389
+ headers = self.headers, # type: ignore[arg-type]
390
+ parameters = parameters, files = files)
391
+
392
+ exchange, hint = self._server.lookup_exchange(query)
393
+
394
+ if ((exchange is None) and (self._missing_request_func is not None)):
395
+ exchange = self._missing_request_func(query) # pylint: disable=not-callable
396
+
397
+ if ((exchange is None) and self._raise_on_404):
398
+ raise ValueError(f"Failed to lookup exchange: '{hint}'.")
399
+
400
+ return exchange, hint
401
+
402
+ class HTTPServerTest(edq.testing.unittest.BaseTest):
403
+ """
404
+ A unit test class that requires a testing HTTP server to be running.
405
+ """
406
+
407
+ server_key: str = ''
408
+ """
409
+ A key to indicate which test server this test class is using.
410
+ By default all test classes share the same server,
411
+ but child classes can set this if they want to control who is using the same server.
412
+ If `tear_down_server` is true, then the relevant server will be stopped (and removed) on a call to tearDownClass(),
413
+ which happens after a test class is complete.
414
+ """
415
+
416
+ tear_down_server: bool = True
417
+ """
418
+ Tear down the relevant test server in tearDownClass().
419
+ If set to false then the server will never get torn down,
420
+ but can be shared between child test classes.
421
+ """
422
+
423
+ skip_test_exchanges_base: bool = False
424
+ """ Skip test_exchanges_base. """
425
+
426
+ _servers: typing.Dict[str, HTTPTestServer] = {}
427
+ """ The active test servers. """
428
+
429
+ _complete_exchange_tests: typing.Set[str] = set()
430
+ """
431
+ Keep track of the servers (by key) that have run their test_exchanges_base.
432
+ This test should only be run once per server.
433
+ """
434
+
435
+ _child_class_setup_called: bool = False
436
+ """ Keep track if the child class setup was called. """
437
+
438
+ @classmethod
439
+ def setUpClass(cls) -> None:
440
+ if (not cls._child_class_setup_called):
441
+ cls.child_class_setup()
442
+ cls._child_class_setup_called = True
443
+
444
+ if (cls.server_key in cls._servers):
445
+ return
446
+
447
+ server = HTTPTestServer()
448
+ cls._servers[cls.server_key] = server
449
+
450
+ cls.setup_server(server)
451
+ server.start()
452
+ cls.post_start_server(server)
453
+
454
+ @classmethod
455
+ def tearDownClass(cls) -> None:
456
+ if (cls.server_key not in cls._servers):
457
+ return
458
+
459
+ server = cls.get_server()
460
+
461
+ if (cls.tear_down_server):
462
+ server.stop()
463
+ del cls._servers[cls.server_key]
464
+ cls._complete_exchange_tests.discard(cls.server_key)
465
+
466
+ @classmethod
467
+ def suite_cleanup(cls) -> None:
468
+ """ Cleanup all test servers. """
469
+
470
+ for server in cls._servers.values():
471
+ server.stop()
472
+
473
+ cls._servers.clear()
474
+
475
+ @classmethod
476
+ def get_server(cls) -> HTTPTestServer:
477
+ """ Get the current HTTP server or raise if there is no server. """
478
+
479
+ server = cls._servers.get(cls.server_key, None)
480
+ if (server is None):
481
+ raise ValueError("Server has not been initialized.")
482
+
483
+ return server
484
+
485
+ @classmethod
486
+ def child_class_setup(cls) -> None:
487
+ """ This function is the recommended time for child classes to set any configuration. """
488
+
489
+ @classmethod
490
+ def setup_server(cls, server: HTTPTestServer) -> None:
491
+ """ An opportunity for child classes to configure the test server before starting it. """
492
+
493
+ @classmethod
494
+ def post_start_server(cls, server: HTTPTestServer) -> None:
495
+ """ An opportunity for child classes to work with the server after it has been started, but before any tests. """
496
+
497
+ @classmethod
498
+ def get_server_url(cls) -> str:
499
+ """ Get the URL for this test's test server. """
500
+
501
+ server = cls.get_server()
502
+
503
+ if (server.port is None):
504
+ raise ValueError("Test server port has not been set.")
505
+
506
+ return f"http://127.0.0.1:{server.port}"
507
+
508
+ def assert_exchange(self, request: edq.util.net.HTTPExchange, response: edq.util.net.HTTPExchange,
509
+ base_url: typing.Union[str, None] = None,
510
+ ) -> requests.Response:
511
+ """
512
+ Assert that the result of making the provided request matches the provided response.
513
+ The same HTTPExchange may be supplied for both the request and response.
514
+ By default, the server's URL will be used as the base URL.
515
+ The full response will be returned (if no assertion is raised).
516
+ """
517
+
518
+ server = self.get_server()
519
+
520
+ if (base_url is None):
521
+ base_url = self.get_server_url()
522
+
523
+ full_response, body = request.make_request(base_url, raise_for_status = True, **server.match_options)
524
+
525
+ match, hint = response.match_response(full_response, override_body = body, **server.match_options)
526
+ if (not match):
527
+ raise AssertionError(f"Exchange does not match: '{hint}'.")
528
+
529
+ return full_response
530
+
531
+ def test_exchanges_base(self) -> None:
532
+ """ Test making a request with each of the loaded exchanges. """
533
+
534
+ # Check if this test has already been run for this server.
535
+ if (self.server_key in self._complete_exchange_tests):
536
+ # Don't skip the test (which will show up in the test output).
537
+ # Instead, just return.
538
+ return
539
+
540
+ if (self.skip_test_exchanges_base):
541
+ self.skipTest('test_exchanges_base has been manually skipped.')
542
+
543
+ self._complete_exchange_tests.add(self.server_key)
544
+
545
+ server = self.get_server()
546
+
547
+ for (i, exchange) in enumerate(server.get_exchanges()):
548
+ base_name = exchange.get_url()
549
+ if (exchange.source_path is not None):
550
+ base_name = os.path.splitext(os.path.basename(exchange.source_path))[0]
551
+
552
+ with self.subTest(msg = f"Case {i} ({base_name}):"):
553
+ self.assert_exchange(exchange, exchange)