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.
- edq/__init__.py +5 -0
- edq/cli/__init__.py +0 -0
- edq/cli/__main__.py +17 -0
- edq/cli/config/__init__.py +3 -0
- edq/cli/config/__main__.py +15 -0
- edq/cli/config/list.py +69 -0
- edq/cli/http/__init__.py +3 -0
- edq/cli/http/__main__.py +15 -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/__main__.py +15 -0
- edq/cli/testing/cli-test.py +49 -0
- edq/cli/version.py +28 -0
- edq/core/__init__.py +0 -0
- edq/core/argparser.py +137 -0
- edq/core/argparser_test.py +124 -0
- edq/core/config.py +268 -0
- edq/core/config_test.py +1038 -0
- edq/core/log.py +101 -0
- edq/core/version.py +6 -0
- edq/procedure/__init__.py +0 -0
- edq/procedure/verify_exchanges.py +85 -0
- edq/py.typed +0 -0
- edq/testing/__init__.py +3 -0
- edq/testing/asserts.py +65 -0
- edq/testing/cli.py +360 -0
- edq/testing/cli_test.py +15 -0
- edq/testing/httpserver.py +578 -0
- edq/testing/httpserver_test.py +424 -0
- edq/testing/run.py +142 -0
- 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/cli/tests/help_base.txt +9 -0
- edq/testing/testdata/cli/tests/platform_skip.txt +5 -0
- edq/testing/testdata/cli/tests/version_base.txt +6 -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 +88 -0
- edq/util/__init__.py +3 -0
- edq/util/cli.py +151 -0
- edq/util/dirent.py +346 -0
- edq/util/dirent_test.py +1004 -0
- edq/util/encoding.py +18 -0
- edq/util/hash.py +41 -0
- edq/util/hash_test.py +89 -0
- edq/util/json.py +180 -0
- edq/util/json_test.py +228 -0
- edq/util/net.py +1047 -0
- edq/util/parse.py +33 -0
- edq/util/pyimport.py +94 -0
- edq/util/pyimport_test.py +119 -0
- edq/util/reflection.py +32 -0
- edq/util/time.py +75 -0
- edq/util/time_test.py +107 -0
- edq_utils-0.2.3.dist-info/METADATA +164 -0
- edq_utils-0.2.3.dist-info/RECORD +88 -0
- edq_utils-0.2.3.dist-info/WHEEL +5 -0
- edq_utils-0.2.3.dist-info/licenses/LICENSE +21 -0
- 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()
|