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.
- edq/__init__.py +1 -1
- edq/cli/config/__init__.py +3 -0
- edq/cli/config/list.py +69 -0
- edq/cli/http/__init__.py +3 -0
- edq/cli/http/exchange-server.py +71 -0
- edq/cli/http/send-exchange.py +45 -0
- edq/cli/http/verify-exchanges.py +38 -0
- edq/cli/testing/__init__.py +3 -0
- edq/cli/testing/cli-test.py +8 -5
- edq/cli/version.py +2 -1
- edq/core/argparser.py +28 -3
- edq/core/config.py +268 -0
- edq/core/config_test.py +1038 -0
- edq/procedure/__init__.py +0 -0
- edq/procedure/verify_exchanges.py +85 -0
- edq/testing/asserts.py +0 -1
- edq/testing/cli.py +17 -1
- edq/testing/httpserver.py +553 -0
- edq/testing/httpserver_test.py +424 -0
- edq/testing/run.py +40 -10
- edq/testing/testdata/cli/data/configs/empty/edq-config.json +1 -0
- edq/testing/testdata/cli/data/configs/simple-1/edq-config.json +4 -0
- edq/testing/testdata/cli/data/configs/simple-2/edq-config.json +3 -0
- edq/testing/testdata/cli/data/configs/value-number/edq-config.json +3 -0
- edq/testing/testdata/cli/tests/config/list/config_list_base.txt +16 -0
- edq/testing/testdata/cli/tests/config/list/config_list_config_value_number.txt +10 -0
- edq/testing/testdata/cli/tests/config/list/config_list_ignore_config.txt +14 -0
- edq/testing/testdata/cli/tests/config/list/config_list_no_config.txt +8 -0
- edq/testing/testdata/cli/tests/config/list/config_list_show_origin.txt +13 -0
- edq/testing/testdata/cli/tests/config/list/config_list_skip_header.txt +10 -0
- edq/testing/testdata/http/exchanges/simple.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/simple_anchor.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/simple_file.httpex.json +10 -0
- edq/testing/testdata/http/exchanges/simple_file_binary.httpex.json +10 -0
- edq/testing/testdata/http/exchanges/simple_file_get_params.httpex.json +14 -0
- edq/testing/testdata/http/exchanges/simple_file_multiple.httpex.json +13 -0
- edq/testing/testdata/http/exchanges/simple_file_name.httpex.json +11 -0
- edq/testing/testdata/http/exchanges/simple_file_post_multiple.httpex.json +13 -0
- edq/testing/testdata/http/exchanges/simple_file_post_params.httpex.json +14 -0
- edq/testing/testdata/http/exchanges/simple_headers.httpex.json +8 -0
- edq/testing/testdata/http/exchanges/simple_jsonresponse_dict.httpex.json +7 -0
- edq/testing/testdata/http/exchanges/simple_jsonresponse_list.httpex.json +9 -0
- edq/testing/testdata/http/exchanges/simple_params.httpex.json +9 -0
- edq/testing/testdata/http/exchanges/simple_post.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/simple_post_params.httpex.json +9 -0
- edq/testing/testdata/http/exchanges/simple_post_urlparams.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/simple_urlparams.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/specialcase_listparams_explicit.httpex.json +8 -0
- edq/testing/testdata/http/exchanges/specialcase_listparams_url.httpex.json +5 -0
- edq/testing/testdata/http/files/a.txt +1 -0
- edq/testing/testdata/http/files/tiny.png +0 -0
- edq/testing/unittest.py +12 -7
- edq/util/dirent.py +2 -0
- edq/util/json.py +21 -4
- edq/util/net.py +895 -0
- edq_utils-0.0.7.dist-info/METADATA +156 -0
- edq_utils-0.0.7.dist-info/RECORD +78 -0
- edq_utils-0.0.5.dist-info/METADATA +0 -63
- edq_utils-0.0.5.dist-info/RECORD +0 -34
- {edq_utils-0.0.5.dist-info → edq_utils-0.0.7.dist-info}/WHEEL +0 -0
- {edq_utils-0.0.5.dist-info → edq_utils-0.0.7.dist-info}/licenses/LICENSE +0 -0
- {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
|