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