edq-utils 0.0.5__py3-none-any.whl → 0.0.7__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 (62) 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 +8 -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/__init__.py +0 -0
  15. edq/procedure/verify_exchanges.py +85 -0
  16. edq/testing/asserts.py +0 -1
  17. edq/testing/cli.py +17 -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/http/exchanges/simple.httpex.json +5 -0
  32. edq/testing/testdata/http/exchanges/simple_anchor.httpex.json +5 -0
  33. edq/testing/testdata/http/exchanges/simple_file.httpex.json +10 -0
  34. edq/testing/testdata/http/exchanges/simple_file_binary.httpex.json +10 -0
  35. edq/testing/testdata/http/exchanges/simple_file_get_params.httpex.json +14 -0
  36. edq/testing/testdata/http/exchanges/simple_file_multiple.httpex.json +13 -0
  37. edq/testing/testdata/http/exchanges/simple_file_name.httpex.json +11 -0
  38. edq/testing/testdata/http/exchanges/simple_file_post_multiple.httpex.json +13 -0
  39. edq/testing/testdata/http/exchanges/simple_file_post_params.httpex.json +14 -0
  40. edq/testing/testdata/http/exchanges/simple_headers.httpex.json +8 -0
  41. edq/testing/testdata/http/exchanges/simple_jsonresponse_dict.httpex.json +7 -0
  42. edq/testing/testdata/http/exchanges/simple_jsonresponse_list.httpex.json +9 -0
  43. edq/testing/testdata/http/exchanges/simple_params.httpex.json +9 -0
  44. edq/testing/testdata/http/exchanges/simple_post.httpex.json +5 -0
  45. edq/testing/testdata/http/exchanges/simple_post_params.httpex.json +9 -0
  46. edq/testing/testdata/http/exchanges/simple_post_urlparams.httpex.json +5 -0
  47. edq/testing/testdata/http/exchanges/simple_urlparams.httpex.json +5 -0
  48. edq/testing/testdata/http/exchanges/specialcase_listparams_explicit.httpex.json +8 -0
  49. edq/testing/testdata/http/exchanges/specialcase_listparams_url.httpex.json +5 -0
  50. edq/testing/testdata/http/files/a.txt +1 -0
  51. edq/testing/testdata/http/files/tiny.png +0 -0
  52. edq/testing/unittest.py +12 -7
  53. edq/util/dirent.py +2 -0
  54. edq/util/json.py +21 -4
  55. edq/util/net.py +895 -0
  56. edq_utils-0.0.7.dist-info/METADATA +156 -0
  57. edq_utils-0.0.7.dist-info/RECORD +78 -0
  58. edq_utils-0.0.5.dist-info/METADATA +0 -63
  59. edq_utils-0.0.5.dist-info/RECORD +0 -34
  60. {edq_utils-0.0.5.dist-info → edq_utils-0.0.7.dist-info}/WHEEL +0 -0
  61. {edq_utils-0.0.5.dist-info → edq_utils-0.0.7.dist-info}/licenses/LICENSE +0 -0
  62. {edq_utils-0.0.5.dist-info → edq_utils-0.0.7.dist-info}/top_level.txt +0 -0
edq/util/net.py ADDED
@@ -0,0 +1,895 @@
1
+ """
2
+ Utilities for network and HTTP.
3
+ """
4
+
5
+ import argparse
6
+ import email.message
7
+ import errno
8
+ import http.server
9
+ import io
10
+ import logging
11
+ import os
12
+ import pathlib
13
+ import socket
14
+ import time
15
+ import typing
16
+ import urllib.parse
17
+
18
+ import requests
19
+ import requests_toolbelt.multipart.decoder
20
+
21
+ import edq.util.dirent
22
+ import edq.util.json
23
+ import edq.util.pyimport
24
+
25
+ DEFAULT_START_PORT: int = 30000
26
+ DEFAULT_END_PORT: int = 40000
27
+ DEFAULT_PORT_SEARCH_WAIT_SEC: float = 0.01
28
+
29
+ DEFAULT_REQUEST_TIMEOUT_SECS: float = 10.0
30
+
31
+ DEFAULT_HTTP_EXCHANGE_EXTENSION: str= '.httpex.json'
32
+
33
+ ANCHOR_HEADER_KEY: str = 'edq-anchor'
34
+ """
35
+ By default, requests made via make_request() will send a header with this key that includes the anchor component of the URL.
36
+ Anchors are not traditionally sent in requests, but this will allow exchanges to capture this extra piece of information.
37
+ """
38
+
39
+ ALLOWED_METHODS: typing.List[str] = [
40
+ 'GET',
41
+ 'POST',
42
+ 'PUT'
43
+ ]
44
+ """ Allowed HTTP methods for an HTTPExchange. """
45
+
46
+ DEFAULT_EXCHANGE_IGNORE_HEADERS: typing.List[str] = [
47
+ 'accept',
48
+ 'accept-encoding',
49
+ 'accept-language',
50
+ 'cache-control',
51
+ 'connection',
52
+ 'content-length',
53
+ 'content-security-policy',
54
+ 'content-type',
55
+ 'cookie',
56
+ 'date',
57
+ 'dnt',
58
+ 'etag',
59
+ 'host',
60
+ 'link',
61
+ 'priority',
62
+ 'referrer-policy',
63
+ 'sec-fetch-dest',
64
+ 'sec-fetch-mode',
65
+ 'sec-fetch-site',
66
+ 'sec-fetch-user',
67
+ 'sec-gpc',
68
+ 'server',
69
+ 'server-timing',
70
+ 'set-cookie',
71
+ 'upgrade-insecure-requests',
72
+ 'user-agent',
73
+ 'x-content-type-options',
74
+ 'x-download-options',
75
+ 'x-permitted-cross-domain-policies',
76
+ 'x-rate-limit-remaining',
77
+ 'x-request-context-id',
78
+ 'x-request-cost',
79
+ 'x-runtime',
80
+ 'x-session-id',
81
+ 'x-xss-protection',
82
+ ANCHOR_HEADER_KEY,
83
+ ]
84
+ """
85
+ By default, ignore these headers during exchange matching.
86
+ Some are sent automatically and we don't need to record (like content-length),
87
+ and some are additional information we don't need.
88
+ """
89
+
90
+ _exchanges_out_dir: typing.Union[str, None] = None
91
+ """ If not None, all requests made via make_request() will be saved as an HTTPExchange in this directory. """
92
+
93
+ _exchanges_clean_func: typing.Union[str, None] = None
94
+ """ If not None, all created exchanges (in HTTPExchange.make_request() and HTTPExchange.from_response()) will use this response modifier. """
95
+
96
+ @typing.runtime_checkable
97
+ class ResponseModifierFunction(typing.Protocol):
98
+ """
99
+ A function that can be used to modify an exchange's response.
100
+ Exchanges can use these functions to normalize their responses before saving.
101
+ """
102
+
103
+ def __call__(self,
104
+ response: requests.Response,
105
+ body: str,
106
+ ) -> str:
107
+ """
108
+ Modify the http response.
109
+ Headers may be modified in the response directly,
110
+ while the modified (or same) body must be returned.
111
+ """
112
+
113
+ class FileInfo(edq.util.json.DictConverter):
114
+ """ Store info about files used in HTTP exchanges. """
115
+
116
+ def __init__(self,
117
+ path: typing.Union[str, None] = None,
118
+ name: typing.Union[str, None] = None,
119
+ content: typing.Union[str, bytes, None] = None,
120
+ **kwargs: typing.Any) -> None:
121
+ # Normalize the path from POSIX-style to the system's style.
122
+ if (path is not None):
123
+ path = str(pathlib.PurePath(pathlib.PurePosixPath(path)))
124
+
125
+ self.path: typing.Union[str, None] = path
126
+ """ The on-disk path to a file. """
127
+
128
+ if ((name is None) and (self.path is not None)):
129
+ name = os.path.basename(self.path)
130
+
131
+ if (name is None):
132
+ raise ValueError("No name was provided for file.")
133
+
134
+ self.name: str = name
135
+ """ The name for this file used in an HTTP request. """
136
+
137
+ self.content: typing.Union[str, bytes, None] = content
138
+ """ The contents of this file. """
139
+
140
+ if ((self.path is None) and (self.content is None)):
141
+ raise ValueError("File must have either path or content specified.")
142
+
143
+ def resolve_path(self, base_dir: str) -> None:
144
+ """ Resolve this path relative to the given base dir. """
145
+
146
+ if ((self.path is None) or os.path.isabs(self.path)):
147
+ return
148
+
149
+ self.path = os.path.abspath(os.path.join(base_dir, self.path))
150
+
151
+ def to_dict(self) -> typing.Dict[str, typing.Any]:
152
+ data = vars(self).copy()
153
+ del data['content']
154
+ return data
155
+
156
+ @classmethod
157
+ def from_dict(cls, data: typing.Dict[str, typing.Any]) -> typing.Any:
158
+ return FileInfo(**data)
159
+
160
+ class HTTPExchange(edq.util.json.DictConverter):
161
+ """
162
+ The request and response making up a full HTTP exchange.
163
+ """
164
+
165
+ def __init__(self,
166
+ method: str = 'GET',
167
+ url: typing.Union[str, None] = None,
168
+ url_path: typing.Union[str, None] = None,
169
+ url_anchor: typing.Union[str, None] = None,
170
+ parameters: typing.Union[typing.Dict[str, typing.Any], None] = None,
171
+ files: typing.Union[typing.List[typing.Union[FileInfo, typing.Dict[str, str]]], None] = None,
172
+ headers: typing.Union[typing.Dict[str, typing.Any], None] = None,
173
+ response_code: int = http.HTTPStatus.OK,
174
+ response_headers: typing.Union[typing.Dict[str, typing.Any], None] = None,
175
+ json_body: typing.Union[bool, None] = None,
176
+ response_body: typing.Union[str, dict, list, None] = None,
177
+ read_write: bool = False,
178
+ source_path: typing.Union[str, None] = None,
179
+ response_modifier: typing.Union[str, None] = None,
180
+ extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
181
+ **kwargs: typing.Any) -> None:
182
+ method = str(method).upper()
183
+ if (method not in ALLOWED_METHODS):
184
+ raise ValueError(f"Got unknown/disallowed method: '{method}'.")
185
+
186
+ self.method: str = method
187
+ """ The HTTP method for this exchange. """
188
+
189
+ url_path, url_anchor, parameters = self._parse_url_components(url, url_path, url_anchor, parameters)
190
+
191
+ self.url_path: str = url_path
192
+ """
193
+ The path portion of the request URL.
194
+ Only the path (not domain, port, params, anchor, etc) should be included.
195
+ """
196
+
197
+ self.url_anchor: typing.Union[str, None] = url_anchor
198
+ """
199
+ The anchor portion of the request URL (if it exists).
200
+ """
201
+
202
+ self.parameters: typing.Dict[str, typing.Any] = parameters
203
+ """
204
+ The parameters/arguments for this request.
205
+ Parameters should be provided here and not encoded into URLs,
206
+ regardless of the request method.
207
+ With the exception of files, all parameters should be placed here.
208
+ """
209
+
210
+ if (files is None):
211
+ files = []
212
+
213
+ parsed_files = []
214
+ for file in files:
215
+ if (isinstance(file, FileInfo)):
216
+ parsed_files.append(file)
217
+ else:
218
+ parsed_files.append(FileInfo(**file))
219
+
220
+ self.files: typing.List[FileInfo] = parsed_files
221
+ """
222
+ A list of files to include in the request.
223
+ The files are represented as dicts with a
224
+ "path" (path to the file on disk) and "name" (the filename to send in the request) field.
225
+ These paths must be POSIX-style paths,
226
+ they will be converted to system-specific paths.
227
+ Once this exchange is ready for use, these paths should be resolved (and probably absolute).
228
+ However, when serialized these paths should probably be relative.
229
+ To reconcile this, resolve_paths() should be called before using this exchange.
230
+ """
231
+
232
+ if (headers is None):
233
+ headers = {}
234
+
235
+ self.headers: typing.Dict[str, typing.Any] = headers
236
+ """ Headers in the request. """
237
+
238
+ self.response_code: int = response_code
239
+ """ The HTTP status code of the response. """
240
+
241
+ if (response_headers is None):
242
+ response_headers = {}
243
+
244
+ self.response_headers: typing.Dict[str, typing.Any] = response_headers
245
+ """ Headers in the response. """
246
+
247
+ if (json_body is None):
248
+ json_body = isinstance(response_body, (dict, list))
249
+
250
+ self.json_body: bool = json_body
251
+ """
252
+ Indicates that the response is JSON and should be converted to/from a string.
253
+ If the response body is passed in a dict/list and this is passed as None,
254
+ then this will be set as true.
255
+ """
256
+
257
+ if (self.json_body and isinstance(response_body, (dict, list))):
258
+ response_body = edq.util.json.dumps(response_body)
259
+
260
+ self.response_body: typing.Union[str, None] = response_body # type: ignore[assignment]
261
+ """
262
+ The response that should be sent in this exchange.
263
+ """
264
+
265
+ self.read_write: bool = read_write
266
+ """
267
+ Indicates that this exchange will change data on the server (regardless of the HTTP method).
268
+ This field may be ignored by test servers,
269
+ but may be observed by tools that generate or validate test data.
270
+ """
271
+
272
+ self.response_modifier: typing.Union[str, None] = response_modifier
273
+ """
274
+ This function reference will be used to modify responses (in HTTPExchange.make_request() and HTTPExchange.from_response())
275
+ before sent back to the caller.
276
+ This reference must be importable via edq.util.pyimport.fetch().
277
+ """
278
+
279
+ self.source_path: typing.Union[str, None] = source_path
280
+ """
281
+ The path that this exchange was loaded from (if it was loaded from a file).
282
+ This value should never be serialized, but can be useful for testing.
283
+ """
284
+
285
+ if (extra_options is None):
286
+ extra_options = {}
287
+
288
+ self.extra_options: typing.Dict[str, typing.Any] = extra_options.copy()
289
+ """
290
+ Additional options for this exchange.
291
+ This library will not use these options, but other's may.
292
+ kwargs will also be added to this.
293
+ """
294
+
295
+ self.extra_options.update(kwargs)
296
+
297
+ def _parse_url_components(self,
298
+ url: typing.Union[str, None] = None,
299
+ url_path: typing.Union[str, None] = None,
300
+ url_anchor: typing.Union[str, None] = None,
301
+ parameters: typing.Union[typing.Dict[str, typing.Any], None] = None,
302
+ ) -> typing.Tuple[str, typing.Union[str, None], typing.Dict[str, typing.Any]]:
303
+ """
304
+ Parse out all URL-based components from raw inputs.
305
+ The URL's path and anchor can either be supplied separately, or as part of the full given URL.
306
+ If content is present in both places, they much match (or an error will be raised).
307
+ Query parameters may be provided in the full URL,
308
+ but will be overwritten by any that are provided separately.
309
+ Any information from the URL aside from the path, anchor/fragment, and query will be ignored.
310
+ Note that path parameters (not query parameters) will be ignored.
311
+ The final url path, url anchor, and parameters will be returned.
312
+ """
313
+
314
+ # Do base initialization and cleanup.
315
+
316
+ if (url_path is not None):
317
+ url_path = url_path.strip()
318
+ if (url_path == ''):
319
+ url_path = None
320
+ else:
321
+ url_path = url_path.lstrip('/')
322
+
323
+ if (url_anchor is not None):
324
+ url_anchor = url_anchor.strip()
325
+ if (url_anchor == ''):
326
+ url_anchor = None
327
+ else:
328
+ url_anchor = url_anchor.lstrip('#')
329
+
330
+ if (parameters is None):
331
+ parameters = {}
332
+
333
+ # Parse the URL (if present).
334
+
335
+ if ((url is not None) and (url.strip() != '')):
336
+ parts = urllib.parse.urlparse(url)
337
+
338
+ # Handle the path.
339
+
340
+ path = parts.path.lstrip('/')
341
+
342
+ if ((url_path is not None) and (url_path != path)):
343
+ raise ValueError(f"Mismatched URL paths where supplied implicitly ('{path}') and explicitly ('{url_path}').")
344
+
345
+ url_path = path
346
+
347
+ # Check the optional anchor/fragment.
348
+
349
+ if (parts.fragment != ''):
350
+ fragment = parts.fragment.lstrip('#')
351
+
352
+ if ((url_anchor is not None) and (url_anchor != fragment)):
353
+ raise ValueError(f"Mismatched URL anchors where supplied implicitly ('{fragment}') and explicitly ('{url_anchor}').")
354
+
355
+ url_anchor = fragment
356
+
357
+ # Check for any parameters.
358
+
359
+ url_params = parse_query_string(parts.query)
360
+ for (key, value) in url_params.items():
361
+ if (key not in parameters):
362
+ parameters[key] = value
363
+
364
+ if (url_path is None):
365
+ raise ValueError('URL path cannot be empty, it must be explicitly set via `url_path`, or indirectly via `url`.')
366
+
367
+ # Sort parameter keys for consistency.
368
+ parameters = {key: parameters[key] for key in sorted(parameters.keys())}
369
+
370
+ return url_path, url_anchor, parameters
371
+
372
+ def resolve_paths(self, base_dir: str) -> None:
373
+ """ Resolve any paths relative to the given base dir. """
374
+
375
+ for file_info in self.files:
376
+ file_info.resolve_path(base_dir)
377
+
378
+ def match(self, query: 'HTTPExchange',
379
+ match_headers: bool = True,
380
+ headers_to_skip: typing.Union[typing.List[str], None] = None,
381
+ params_to_skip: typing.Union[typing.List[str], None] = None,
382
+ **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]:
383
+ """
384
+ Check if this exchange matches the query exchange.
385
+ If they match, `(True, None)` will be returned.
386
+ If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is.
387
+
388
+ Note that this is not an equality check,
389
+ as a query exchange is often missing the response components.
390
+ This method is often invoked the see if an incoming HTTP request (the query) matches an existing exchange.
391
+ """
392
+
393
+ if (query.method != self.method):
394
+ return False, f"HTTP method does not match (query = {query.method}, target = {self.method})."
395
+
396
+ if (query.url_path != self.url_path):
397
+ return False, f"URL path does not match (query = {query.url_path}, target = {self.url_path})."
398
+
399
+ if (query.url_anchor != self.url_anchor):
400
+ return False, f"URL anchor does not match (query = {query.url_anchor}, target = {self.url_anchor})."
401
+
402
+ if (headers_to_skip is None):
403
+ headers_to_skip = DEFAULT_EXCHANGE_IGNORE_HEADERS
404
+
405
+ if (params_to_skip is None):
406
+ params_to_skip = []
407
+
408
+ if (match_headers):
409
+ match, hint = self._match_dict('header', query.headers, self.headers, headers_to_skip)
410
+ if (not match):
411
+ return False, hint
412
+
413
+ match, hint = self._match_dict('parameter', query.parameters, self.parameters, params_to_skip)
414
+ if (not match):
415
+ return False, hint
416
+
417
+ # Check file names, not file content.
418
+ query_filenames = {file.name for file in query.files}
419
+ target_filenames = {file.name for file in self.files}
420
+ if (query_filenames != target_filenames):
421
+ return False, f"File names do not match (query = {query_filenames}, target = {target_filenames})."
422
+
423
+ return True, None
424
+
425
+ def _match_dict(self, label: str,
426
+ query_dict: typing.Dict[str, typing.Any],
427
+ target_dict: typing.Dict[str, typing.Any],
428
+ keys_to_skip: typing.Union[typing.List[str], None] = None,
429
+ query_label: str = 'query',
430
+ target_label: str = 'target',
431
+ normalize_key_case: bool = True,
432
+ ) -> typing.Tuple[bool, typing.Union[str, None]]:
433
+ """ A subcheck in match(), specifically for a dictionary. """
434
+
435
+ if (keys_to_skip is None):
436
+ keys_to_skip = []
437
+
438
+ if (normalize_key_case):
439
+ keys_to_skip = [key.lower() for key in keys_to_skip]
440
+ query_dict = {key.lower(): value for (key, value) in query_dict.items()}
441
+ target_dict = {key.lower(): value for (key, value) in target_dict.items()}
442
+
443
+ query_keys = set(query_dict.keys()) - set(keys_to_skip)
444
+ target_keys = set(target_dict.keys()) - set(keys_to_skip)
445
+
446
+ if (query_keys != target_keys):
447
+ return False, f"{label.title()} keys do not match ({query_label} = {query_keys}, {target_label} = {target_keys})."
448
+
449
+ for key in sorted(query_keys):
450
+ query_value = query_dict[key]
451
+ target_value = target_dict[key]
452
+
453
+ if (query_value != target_value):
454
+ comparison = f"{query_label} = '{query_value}', {target_label} = '{target_value}'"
455
+ return False, f"{label.title()} '{key}' has a non-matching value ({comparison})."
456
+
457
+ return True, None
458
+
459
+ def get_url(self) -> str:
460
+ """ Get the URL path and anchor combined. """
461
+
462
+ url = self.url_path
463
+
464
+ if (self.url_anchor is not None):
465
+ url += ('#' + self.url_anchor)
466
+
467
+ return url
468
+
469
+ def make_request(self, base_url: str, raise_for_status: bool = True, **kwargs: typing.Any) -> typing.Tuple[requests.Response, str]:
470
+ """ Perform the HTTP request described by this exchange. """
471
+
472
+ files = []
473
+ for file_info in self.files:
474
+ content = file_info.content
475
+ if (content is None):
476
+ content = open(file_info.path, 'rb') # type: ignore[assignment,arg-type] # pylint: disable=consider-using-with
477
+
478
+ files.append((file_info.name, content))
479
+
480
+ url = f"{base_url}/{self.get_url()}"
481
+
482
+ response, body = make_request(self.method, url,
483
+ headers = self.headers,
484
+ data = self.parameters,
485
+ files = files,
486
+ raise_for_status = raise_for_status,
487
+ **kwargs,
488
+ )
489
+
490
+ if (self.response_modifier is not None):
491
+ modify_func = edq.util.pyimport.fetch(self.response_modifier)
492
+ body = modify_func(response, body)
493
+
494
+ return response, body
495
+
496
+ def match_response(self, response: requests.Response,
497
+ override_body: typing.Union[str, None] = None,
498
+ headers_to_skip: typing.Union[typing.List[str], None] = None,
499
+ **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]:
500
+ """
501
+ Check if this exchange matches the given response.
502
+ If they match, `(True, None)` will be returned.
503
+ If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is.
504
+ """
505
+
506
+ if (headers_to_skip is None):
507
+ headers_to_skip = DEFAULT_EXCHANGE_IGNORE_HEADERS
508
+
509
+ response_body = override_body
510
+ if (response_body is None):
511
+ response_body = response.text
512
+
513
+ if (self.response_code != response.status_code):
514
+ return False, f"http status code does match (expected: {self.response_code}, actual: {response.status_code})"
515
+
516
+ expected_body = self.response_body
517
+ actual_body = None
518
+
519
+ if (self.json_body):
520
+ actual_body = response.json()
521
+
522
+ # Normalize the actual and expected bodies.
523
+
524
+ actual_body = edq.util.json.dumps(actual_body)
525
+
526
+ if (isinstance(expected_body, str)):
527
+ expected_body = edq.util.json.loads(expected_body)
528
+
529
+ expected_body = edq.util.json.dumps(expected_body)
530
+ else:
531
+ actual_body = response_body
532
+
533
+ if (self.response_body != actual_body):
534
+ body_hint = f"expected: '{self.response_body}', actual: '{actual_body}'"
535
+ return False, f"body does not match ({body_hint})"
536
+
537
+ match, hint = self._match_dict('header', response.headers, self.response_headers,
538
+ keys_to_skip = headers_to_skip,
539
+ query_label = 'response', target_label = 'exchange')
540
+
541
+ if (not match):
542
+ return False, hint
543
+
544
+ return True, None
545
+
546
+ def to_dict(self) -> typing.Dict[str, typing.Any]:
547
+ return vars(self)
548
+
549
+ @classmethod
550
+ def from_dict(cls, data: typing.Dict[str, typing.Any]) -> typing.Any:
551
+ return HTTPExchange(**data)
552
+
553
+ @classmethod
554
+ def from_path(cls, path: str,
555
+ set_source_path: bool = True,
556
+ ) -> 'HTTPExchange':
557
+ """
558
+ Load an exchange from a file.
559
+ This will also handle setting the exchanges source path (if specified) and resolving the exchange's paths.
560
+ """
561
+
562
+ exchange = typing.cast(HTTPExchange, edq.util.json.load_object_path(path, HTTPExchange))
563
+
564
+ if (set_source_path):
565
+ exchange.source_path = os.path.abspath(path)
566
+
567
+ exchange.resolve_paths(os.path.abspath(os.path.dirname(path)))
568
+
569
+ return exchange
570
+
571
+ @classmethod
572
+ def from_response(cls,
573
+ response: requests.Response,
574
+ headers_to_skip: typing.Union[typing.List[str], None] = None,
575
+ params_to_skip: typing.Union[typing.List[str], None] = None,
576
+ ) -> 'HTTPExchange':
577
+ """ Create a full excahnge from a response. """
578
+
579
+ if (headers_to_skip is None):
580
+ headers_to_skip = DEFAULT_EXCHANGE_IGNORE_HEADERS
581
+
582
+ if (params_to_skip is None):
583
+ params_to_skip = []
584
+
585
+ body = response.text
586
+
587
+ # Use a clean function (if one exists).
588
+ if (_exchanges_clean_func is not None):
589
+ modify_func = edq.util.pyimport.fetch(_exchanges_clean_func)
590
+ body = modify_func(response, body)
591
+
592
+ request_headers = {key.lower().strip(): value for (key, value) in response.request.headers.items()}
593
+ response_headers = {key.lower().strip(): value for (key, value) in response.headers.items()}
594
+
595
+ # Clean headers.
596
+ for key in headers_to_skip:
597
+ key = key.lower()
598
+
599
+ request_headers.pop(key, None)
600
+ response_headers.pop(key, None)
601
+
602
+ request_data, request_files = parse_request_data(response.request.url, response.request.headers, response.request.body)
603
+
604
+ # Clean parameters.
605
+ for key in params_to_skip:
606
+ request_data.pop(key, None)
607
+
608
+ files = [FileInfo(name = name, content = content) for (name, content) in request_files.items()]
609
+
610
+ data = {
611
+ 'method': response.request.method,
612
+ 'url': response.request.url,
613
+ 'url_anchor': response.request.headers.get(ANCHOR_HEADER_KEY, None),
614
+ 'parameters': request_data,
615
+ 'files': files,
616
+ 'headers': request_headers,
617
+ 'response_code': response.status_code,
618
+ 'response_headers': response_headers,
619
+ 'response_body': body,
620
+ 'response_modifier': _exchanges_clean_func,
621
+ }
622
+
623
+ return HTTPExchange(**data)
624
+
625
+ def find_open_port(
626
+ start_port: int = DEFAULT_START_PORT, end_port: int = DEFAULT_END_PORT,
627
+ wait_time: float = DEFAULT_PORT_SEARCH_WAIT_SEC) -> int:
628
+ """
629
+ Find an open port on this machine within the given range (inclusive).
630
+ If no open port is found, an error is raised.
631
+ """
632
+
633
+ for port in range(start_port, end_port + 1):
634
+ try:
635
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
636
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
637
+ sock.bind(('127.0.0.1', port))
638
+
639
+ # Explicitly close the port and wait a short amount of time for the port to clear.
640
+ # This should not be required because of the socket option above,
641
+ # but the cost is small.
642
+ sock.close()
643
+ time.sleep(DEFAULT_PORT_SEARCH_WAIT_SEC)
644
+
645
+ return port
646
+ except socket.error as ex:
647
+ sock.close()
648
+
649
+ if (ex.errno == errno.EADDRINUSE):
650
+ continue
651
+
652
+ # Unknown error.
653
+ raise ex
654
+
655
+ raise ValueError(f"Could not find open port in [{start_port}, {end_port}].")
656
+
657
+ def make_request(method: str, url: str,
658
+ headers: typing.Union[typing.Dict[str, typing.Any], None] = None,
659
+ data: typing.Union[typing.Dict[str, typing.Any], None] = None,
660
+ files: typing.Union[typing.List[typing.Any], None] = None,
661
+ raise_for_status: bool = True,
662
+ timeout_secs: float = DEFAULT_REQUEST_TIMEOUT_SECS,
663
+ output_dir: typing.Union[str, None] = None,
664
+ send_anchor_header: bool = True,
665
+ headers_to_skip: typing.Union[typing.List[str], None] = None,
666
+ params_to_skip: typing.Union[typing.List[str], None] = None,
667
+ http_exchange_extension: str = DEFAULT_HTTP_EXCHANGE_EXTENSION,
668
+ add_http_prefix: bool = True,
669
+ **kwargs: typing.Any) -> typing.Tuple[requests.Response, str]:
670
+ """
671
+ Make an HTTP request and return the response object and text body.
672
+ """
673
+
674
+ if (add_http_prefix and (not url.lower().startswith('http'))):
675
+ url = 'http://' + url
676
+
677
+ if (output_dir is None):
678
+ output_dir = _exchanges_out_dir
679
+
680
+ if (headers is None):
681
+ headers = {}
682
+
683
+ if (data is None):
684
+ data = {}
685
+
686
+ if (files is None):
687
+ files = []
688
+
689
+ # Add in the anchor as a header (since it is not traditionally sent in an HTTP request).
690
+ if (send_anchor_header):
691
+ headers = headers.copy()
692
+
693
+ parts = urllib.parse.urlparse(url)
694
+ headers[ANCHOR_HEADER_KEY] = parts.fragment.lstrip('#')
695
+
696
+ options = {
697
+ 'headers': headers,
698
+ 'files': files,
699
+ 'timeout': timeout_secs,
700
+ }
701
+
702
+ if (method == 'GET'):
703
+ options['params'] = data
704
+ else:
705
+ options['data'] = data
706
+
707
+ logging.debug("Making %s request: '%s'.", method, url)
708
+ response = requests.request(method, url, **options)
709
+
710
+ body = response.text
711
+ logging.debug("Response:\n%s", body)
712
+
713
+ if (raise_for_status):
714
+ # Handle 404s a little special, as their body may contain useful information.
715
+ if ((response.status_code == http.HTTPStatus.NOT_FOUND) and (body is not None) and (body.strip() != '')):
716
+ response.reason += f" (Body: '{body.strip()}')"
717
+
718
+ response.raise_for_status()
719
+
720
+ if (output_dir is not None):
721
+ exchange = HTTPExchange.from_response(response, headers_to_skip = headers_to_skip, params_to_skip = params_to_skip)
722
+
723
+ path = os.path.abspath(os.path.join(output_dir, *exchange.get_url().split('/')))
724
+
725
+ query = urllib.parse.urlencode(exchange.parameters)
726
+ if (query != ''):
727
+ # Note that the '?' is URL encoded.
728
+ path += f"%3F{query}"
729
+
730
+ path += f"_{method}{http_exchange_extension}"
731
+
732
+ edq.util.dirent.mkdir(os.path.dirname(path))
733
+ edq.util.json.dump_path(exchange, path, indent = 4, sort_keys = False)
734
+
735
+ return response, body
736
+
737
+ def make_get(url: str, **kwargs: typing.Any) -> typing.Tuple[requests.Response, str]:
738
+ """
739
+ Make a GET request and return the response object and text body.
740
+ """
741
+
742
+ return make_request('GET', url, **kwargs)
743
+
744
+ def make_post(url: str, **kwargs: typing.Any) -> typing.Tuple[requests.Response, str]:
745
+ """
746
+ Make a POST request and return the response object and text body.
747
+ """
748
+
749
+ return make_request('POST', url, **kwargs)
750
+
751
+ def parse_request_data(
752
+ url: str,
753
+ headers: typing.Union[email.message.Message, typing.Dict[str, typing.Any]],
754
+ body: typing.Union[bytes, str, io.BufferedIOBase],
755
+ ) -> typing.Tuple[typing.Dict[str, typing.Any], typing.Dict[str, bytes]]:
756
+ """ Parse data and files from an HTTP request URL and body. """
757
+
758
+ # Parse data from the request body.
759
+ request_data, request_files = parse_request_body_data(headers, body)
760
+
761
+ # Parse parameters from the URL.
762
+ url_parts = urllib.parse.urlparse(url)
763
+ request_data.update(parse_query_string(url_parts.query))
764
+
765
+ return request_data, request_files
766
+
767
+ def parse_request_body_data(
768
+ headers: typing.Union[email.message.Message, typing.Dict[str, typing.Any]],
769
+ body: typing.Union[bytes, str, io.BufferedIOBase],
770
+ ) -> typing.Tuple[typing.Dict[str, typing.Any], typing.Dict[str, bytes]]:
771
+ """ Parse data and files from an HTTP request body. """
772
+
773
+ data: typing.Dict[str, typing.Any] = {}
774
+ files: typing.Dict[str, bytes] = {}
775
+
776
+ length = int(headers.get('Content-Length', 0))
777
+ if (length == 0):
778
+ return data, files
779
+
780
+ if (isinstance(body, io.BufferedIOBase)):
781
+ raw_content = body.read(length)
782
+ elif (isinstance(body, str)):
783
+ raw_content = body.encode(edq.util.dirent.DEFAULT_ENCODING)
784
+ else:
785
+ raw_content = body
786
+
787
+ content_type = headers.get('Content-Type', '')
788
+
789
+ if (content_type in ['', 'application/x-www-form-urlencoded']):
790
+ data = parse_query_string(raw_content.decode(edq.util.dirent.DEFAULT_ENCODING).strip())
791
+ return data, files
792
+
793
+ if (content_type.startswith('multipart/form-data')):
794
+ decoder = requests_toolbelt.multipart.decoder.MultipartDecoder(
795
+ raw_content, content_type, encoding = edq.util.dirent.DEFAULT_ENCODING)
796
+
797
+ for multipart_section in decoder.parts:
798
+ values = parse_content_dispositions(multipart_section.headers)
799
+
800
+ name = values.get('name', None)
801
+ if (name is None):
802
+ raise ValueError("Could not find name for multipart section.")
803
+
804
+ # Look for a "filename" field to indicate a multipart section is a file.
805
+ # The file's desired name is still in "name", but an alternate name is in "filename".
806
+ if ('filename' in values):
807
+ filename = values.get('name', '')
808
+ if (filename == ''):
809
+ raise ValueError("Unable to find filename for multipart section.")
810
+
811
+ files[filename] = multipart_section.content
812
+ else:
813
+ # Normal Parameter
814
+ data[name] = multipart_section.text
815
+
816
+ return data, files
817
+
818
+ raise ValueError(f"Unknown content type: '{content_type}'.")
819
+
820
+ def parse_content_dispositions(headers: typing.Union[email.message.Message, typing.Dict[str, typing.Any]]) -> typing.Dict[str, typing.Any]:
821
+ """ Parse a request's content dispositions from headers. """
822
+
823
+ values = {}
824
+ for (key, value) in headers.items():
825
+ if (isinstance(key, bytes)):
826
+ key = key.decode(edq.util.dirent.DEFAULT_ENCODING)
827
+
828
+ if (isinstance(value, bytes)):
829
+ value = value.decode(edq.util.dirent.DEFAULT_ENCODING)
830
+
831
+ key = key.strip().lower()
832
+ if (key != 'content-disposition'):
833
+ continue
834
+
835
+ # The Python stdlib recommends using the email library for this parsing,
836
+ # but I have not had a good experience with it.
837
+ for part in value.strip().split(';'):
838
+ part = part.strip()
839
+
840
+ parts = part.split('=')
841
+ if (len(parts) != 2):
842
+ continue
843
+
844
+ cd_key = parts[0].strip()
845
+ cd_value = parts[1].strip().strip('"')
846
+
847
+ values[cd_key] = cd_value
848
+
849
+ return values
850
+
851
+ def parse_query_string(text: str,
852
+ replace_single_lists: bool = True,
853
+ ) -> typing.Dict[str, typing.Any]:
854
+ """
855
+ Parse a query string (like urllib.parse.parse_qs()), and normalize the result.
856
+ If specified, lists with single values (as returned from urllib.parse.parse_qs()) will be replaced with the single value.
857
+ """
858
+
859
+ results = urllib.parse.parse_qs(text)
860
+ for (key, value) in results.items():
861
+ if (replace_single_lists and (len(value) == 1)):
862
+ results[key] = value[0] # type: ignore[assignment]
863
+
864
+ return results
865
+
866
+ def set_cli_args(parser: argparse.ArgumentParser, extra_state: typing.Dict[str, typing.Any]) -> None:
867
+ """
868
+ Set common CLI arguments.
869
+ This is a sibling to init_from_args(), as the arguments set here can be interpreted there.
870
+ """
871
+
872
+ parser.add_argument('--http-exchanges-out-dir', dest = 'http_exchanges_out_dir',
873
+ action = 'store', type = str, default = None,
874
+ help = 'If set, write all outgoing HTTP requests as exchanges to this directory.')
875
+
876
+ parser.add_argument('--http-exchanges-clean-func', dest = 'http_exchanges_clean_func',
877
+ action = 'store', type = str, default = None,
878
+ help = 'If set, default all created exchanges to this modifier function.')
879
+
880
+ def init_from_args(
881
+ parser: argparse.ArgumentParser,
882
+ args: argparse.Namespace,
883
+ extra_state: typing.Dict[str, typing.Any]) -> None:
884
+ """
885
+ Take in args from a parser that was passed to set_cli_args(),
886
+ and call init() with the appropriate arguments.
887
+ """
888
+
889
+ global _exchanges_out_dir # pylint: disable=global-statement
890
+ if (args.http_exchanges_out_dir is not None):
891
+ _exchanges_out_dir = args.http_exchanges_out_dir
892
+
893
+ global _exchanges_clean_func # pylint: disable=global-statement
894
+ if (args.http_exchanges_clean_func is not None):
895
+ _exchanges_clean_func = args.http_exchanges_clean_func