edq.net.exchange

  1import copy
  2import http
  3import os
  4import pathlib
  5import typing
  6import urllib.parse
  7
  8import requests
  9
 10import edq.net.settings
 11import edq.net.util
 12import edq.util.dirent
 13import edq.util.encoding
 14import edq.util.hash
 15import edq.util.json
 16import edq.util.parse
 17import edq.util.pyimport
 18import edq.util.serial
 19
 20DEFAULT_HTTP_EXCHANGE_EXTENSION: str= '.httpex.json'
 21
 22QUERY_CLIP_LENGTH: int = 100
 23""" If the filename of an HTTPExhange being saved is longer than this, then clip it. """
 24
 25ALLOWED_METHODS: typing.List[str] = [
 26    'DELETE',
 27    'GET',
 28    'HEAD',
 29    'OPTIONS',
 30    'PATCH',
 31    'POST',
 32    'PUT',
 33]
 34""" Allowed HTTP methods for an HTTPExchange. """
 35
 36class FileInfo(edq.util.serial.DictConverter):
 37    """ Store info about files used in HTTP exchanges. """
 38
 39    def __init__(self,
 40            path: typing.Union[str, None] = None,
 41            name: typing.Union[str, None] = None,
 42            content: typing.Union[str, bytes, None] = None,
 43            b64_encoded: bool = False,
 44            **kwargs: typing.Any) -> None:
 45        # Normalize the path from POSIX-style to the system's style.
 46        if (path is not None):
 47            path = str(pathlib.PurePath(pathlib.PurePosixPath(path)))
 48
 49        self.path: typing.Union[str, None] = path
 50        """ The on-disk path to a file. """
 51
 52        if ((name is None) and (self.path is not None)):
 53            name = os.path.basename(self.path)
 54
 55        if (name is None):
 56            raise ValueError("No name was provided for file.")
 57
 58        self.name: str = name
 59        """ The name for this file used in an HTTP request. """
 60
 61        self.content: typing.Union[str, bytes, None] = content
 62        """ The contents of this file. """
 63
 64        self.b64_encoded: bool = b64_encoded
 65        """ Whether the content is a string encoded in Base64. """
 66
 67        if ((self.path is None) and (self.content is None)):
 68            raise ValueError("File must have either path or content specified.")
 69
 70    def resolve_path(self, base_dir: str, load_file: bool = True) -> None:
 71        """ Resolve this path relative to the given base dir. """
 72
 73        if ((self.path is not None) and (not os.path.isabs(self.path))):
 74            self.path = os.path.abspath(os.path.join(base_dir, self.path))
 75
 76        if ((self.path is not None) and (self.content is None) and load_file):
 77            self.content = edq.util.dirent.read_file_bytes(self.path)
 78
 79    def hash_content(self) -> str:
 80        """
 81        Compute a hash for the content present.
 82        If no content is provided, use the path.
 83        """
 84
 85        hash_content = self.content
 86
 87        if (self.b64_encoded and isinstance(hash_content, str)):
 88            hash_content = edq.util.encoding.from_base64(hash_content)
 89
 90        if (hash_content is None):
 91            hash_content = self.path
 92
 93        return edq.util.hash.sha256_hex(hash_content)
 94
 95    def to_dict(self,
 96            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 97            ) -> typing.Dict[str, typing.Any]:
 98        data = vars(self).copy()
 99
100        # JSON does not support raw bytes, so we will need to base64 encode any binary content.
101        if (isinstance(self.content, bytes)):
102            data['content'] = edq.util.encoding.to_base64(self.content)
103            data['b64_encoded'] = True
104
105        return data
106
107    @classmethod
108    def from_dict(cls,
109            data: typing.Dict[str, typing.Any],
110            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
111            ) -> typing.Any:
112        return FileInfo(**data)
113
114class HTTPExchange(edq.util.serial.DictConverter):
115    """
116    The request and response making up a full HTTP exchange.
117    """
118
119    def __init__(self,
120            method: str = 'GET',
121            url: typing.Union[str, None] = None,
122            url_path: typing.Union[str, None] = None,
123            url_anchor: typing.Union[str, None] = None,
124            parameters: typing.Union[typing.Dict[str, typing.Any], None] = None,
125            files: typing.Union[typing.List[typing.Union[FileInfo, typing.Dict[str, typing.Any]]], None] = None,
126            headers: typing.Union[typing.Dict[str, typing.Any], None] = None,
127            allow_redirects: typing.Union[bool, None] = None,
128            response_code: int = http.HTTPStatus.OK,
129            response_headers: typing.Union[typing.Dict[str, typing.Any], None] = None,
130            json_body: typing.Union[bool, None] = None,
131            response_body: typing.Union[str, dict, list, None] = None,
132            source_path: typing.Union[str, None] = None,
133            response_modifier: typing.Union[str, None] = None,
134            finalize: typing.Union[str, None] = None,
135            extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
136            **kwargs: typing.Any) -> None:
137        method = str(method).upper()
138        if (method not in ALLOWED_METHODS):
139            raise ValueError(f"Got unknown/disallowed method: '{method}'.")
140
141        self.method: str = method
142        """ The HTTP method for this exchange. """
143
144        url_path, url_anchor, parameters = self._parse_url_components(url, url_path, url_anchor, parameters)
145
146        self.url_path: str = url_path
147        """
148        The path portion of the request URL.
149        Only the path (not domain, port, params, anchor, etc) should be included.
150        """
151
152        self.url_anchor: typing.Union[str, None] = url_anchor
153        """
154        The anchor portion of the request URL (if it exists).
155        """
156
157        self.parameters: typing.Dict[str, typing.Any] = parameters
158        """
159        The parameters/arguments for this request.
160        Parameters should be provided here and not encoded into URLs,
161        regardless of the request method.
162        With the exception of files, all parameters should be placed here.
163        """
164
165        if (files is None):
166            files = []
167
168        parsed_files = []
169        for file in files:
170            if (isinstance(file, FileInfo)):
171                parsed_files.append(file)
172            else:
173                parsed_files.append(FileInfo(**file))
174
175        self.files: typing.List[FileInfo] = parsed_files
176        """
177        A list of files to include in the request.
178        The files are represented as dicts with a
179        "path" (path to the file on disk) and "name" (the filename to send in the request) field.
180        These paths must be POSIX-style paths,
181        they will be converted to system-specific paths.
182        Once this exchange is ready for use, these paths should be resolved (and probably absolute).
183        However, when serialized these paths should probably be relative.
184        To reconcile this, resolve_paths() should be called before using this exchange.
185        """
186
187        if (headers is None):
188            headers = {}
189
190        self.headers: typing.Dict[str, typing.Any] = {key.lower().strip(): value for (key, value) in headers.items()}
191        """
192        Headers in the request.
193        All header keys are stored as lower case.
194        """
195
196        if (allow_redirects is None):
197            allow_redirects = True
198
199        self.allow_redirects: bool = allow_redirects
200        """ Follow redirects. """
201
202        self.response_code: int = response_code
203        """ The HTTP status code of the response. """
204
205        if (response_headers is None):
206            response_headers = {}
207
208        self.response_headers: typing.Dict[str, typing.Any] = {key.lower().strip(): value for (key, value) in response_headers.items()}
209        """
210        Headers in the response.
211        All header keys are stored as lower case.
212        """
213
214        if (json_body is None):
215            json_body = isinstance(response_body, (dict, list))
216
217        self.json_body: bool = json_body
218        """
219        Indicates that the response is JSON and should be converted to/from a string.
220        If the response body is passed in a dict/list and this is passed as None,
221        then this will be set as true.
222        """
223
224        if (self.json_body and isinstance(response_body, (dict, list))):
225            response_body = edq.util.json.dumps(response_body)
226
227        self.response_body: typing.Union[str, None] = response_body  # type: ignore[assignment]
228        """
229        The response that should be sent in this exchange.
230        """
231
232        self.response_modifier: typing.Union[str, None] = response_modifier
233        """
234        This function reference will be used to modify responses (in HTTPExchange.make_request() and HTTPExchange.from_response())
235        before sent back to the caller.
236        This reference must be importable via edq.util.pyimport.fetch().
237        """
238
239        self.finalize: typing.Union[str, None] = finalize
240        """
241        This function reference will be used to finalize echanges when created using from_response() before sent back to the caller.
242        This reference must be importable via edq.util.pyimport.fetch().
243        """
244
245        self.source_path: typing.Union[str, None] = source_path
246        """
247        The path that this exchange was loaded from (if it was loaded from a file).
248        This value should never be seriald, but can be useful for testing.
249        """
250
251        if (extra_options is None):
252            extra_options = {}
253
254        self.extra_options: typing.Dict[str, typing.Any] = extra_options.copy()
255        """
256        Additional options for this exchange.
257        This library will not use these options, but other's may.
258        kwargs will also be added to this.
259        """
260
261        self.extra_options.update(kwargs)
262
263    def _parse_url_components(self,
264            url: typing.Union[str, None] = None,
265            url_path: typing.Union[str, None] = None,
266            url_anchor: typing.Union[str, None] = None,
267            parameters: typing.Union[typing.Dict[str, typing.Any], None] = None,
268            ) -> typing.Tuple[str, typing.Union[str, None], typing.Dict[str, typing.Any]]:
269        """
270        Parse out all URL-based components from raw inputs.
271        The URL's path and anchor can either be supplied separately, or as part of the full given URL.
272        If content is present in both places, they much match (or an error will be raised).
273        Query parameters may be provided in the full URL,
274        but will be overwritten by any that are provided separately.
275        Any information from the URL aside from the path, anchor/fragment, and query will be ignored.
276        Note that path parameters (not query parameters) will be ignored.
277        The final url path, url anchor, and parameters will be returned.
278        """
279
280        # Do base initialization and cleanup.
281
282        if (url_path is not None):
283            url_path = url_path.strip()
284            if (url_path == ''):
285                url_path = ''
286            else:
287                url_path = url_path.lstrip('/')
288
289        if (url_anchor is not None):
290            url_anchor = url_anchor.strip()
291            if (url_anchor == ''):
292                url_anchor = None
293            else:
294                url_anchor = url_anchor.lstrip('#')
295
296        if (parameters is None):
297            parameters = {}
298
299        # Parse the URL (if present).
300
301        if ((url is not None) and (url.strip() != '')):
302            parts = urllib.parse.urlparse(url)
303
304            # Handle the path.
305
306            path = parts.path.lstrip('/')
307
308            if ((url_path is not None) and (url_path != path)):
309                raise ValueError(f"Mismatched URL paths where supplied implicitly ('{path}') and explicitly ('{url_path}').")
310
311            url_path = path
312
313            # Check the optional anchor/fragment.
314
315            if (parts.fragment != ''):
316                fragment = parts.fragment.lstrip('#')
317
318                if ((url_anchor is not None) and (url_anchor != fragment)):
319                    raise ValueError(f"Mismatched URL anchors where supplied implicitly ('{fragment}') and explicitly ('{url_anchor}').")
320
321                url_anchor = fragment
322
323            # Check for any parameters.
324
325            url_params = edq.net.util.parse_query_string(parts.query)
326            for (key, value) in url_params.items():
327                if (key not in parameters):
328                    parameters[key] = value
329
330        if (url_path is None):
331            raise ValueError('URL path cannot be empty, it must be explicitly set via `url_path`, or indirectly via `url`.')
332
333        # Sort parameter keys for consistency.
334        parameters = {key: parameters[key] for key in sorted(parameters.keys())}
335
336        return url_path, url_anchor, parameters
337
338    def resolve_paths(self, base_dir: str) -> None:
339        """ Resolve any paths relative to the given base dir. """
340
341        for file_info in self.files:
342            file_info.resolve_path(base_dir)
343
344    def match(self, query: 'HTTPExchange',
345            match_headers: bool = True,
346            headers_to_skip: typing.Union[typing.List[str], None] = None,
347            params_to_skip: typing.Union[typing.List[str], None] = None,
348            **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]:
349        """
350        Check if this exchange matches the query exchange.
351        If they match, `(True, None)` will be returned.
352        If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is.
353
354        Note that this is not an equality check,
355        as a query exchange is often missing the response components.
356        This method is often invoked the see if an incoming HTTP request (the query) matches an existing exchange.
357        """
358
359        if (query.method != self.method):
360            return False, f"HTTP method does not match (query = {query.method}, target = {self.method})."
361
362        if (query.url_path != self.url_path):
363            return False, f"URL path does not match (query = {query.url_path}, target = {self.url_path})."
364
365        if (query.url_anchor != self.url_anchor):
366            return False, f"URL anchor does not match (query = {query.url_anchor}, target = {self.url_anchor})."
367
368        if (headers_to_skip is None):
369            headers_to_skip = edq.net.settings.get_exchanges_ignore_headers()
370
371        if (params_to_skip is None):
372            params_to_skip = []
373
374        if (match_headers):
375            match, hint = self._match_dict('header', query.headers, self.headers, headers_to_skip)
376            if (not match):
377                return False, hint
378
379        match, hint = self._match_dict('parameter', query.parameters, self.parameters, params_to_skip)
380        if (not match):
381            return False, hint
382
383        # Check file names and hash contents.
384        query_filenames = {(file.name, file.hash_content()) for file in query.files}
385        target_filenames = {(file.name, file.hash_content()) for file in self.files}
386        if (query_filenames != target_filenames):
387            return False, f"File names do not match (query = {query_filenames}, target = {target_filenames})."
388
389        return True, None
390
391    def _match_dict(self,
392            label: str,
393            query_dict: typing.Dict[str, typing.Any],
394            target_dict: typing.Dict[str, typing.Any],
395            keys_to_skip: typing.Union[typing.List[str], None] = None,
396            query_label: str = 'query',
397            target_label: str = 'target',
398            normalize_key_case: bool = True,
399            ) -> typing.Tuple[bool, typing.Union[str, None]]:
400        """ A subcheck in match(), specifically for a dictionary. """
401
402        if (keys_to_skip is None):
403            keys_to_skip = []
404
405        if (normalize_key_case):
406            keys_to_skip = [key.lower() for key in keys_to_skip]
407            query_dict = {key.lower(): value for (key, value) in query_dict.items()}
408            target_dict = {key.lower(): value for (key, value) in target_dict.items()}
409
410        query_keys = set(query_dict.keys()) - set(keys_to_skip)
411        target_keys = set(target_dict.keys()) - set(keys_to_skip)
412
413        if (query_keys != target_keys):
414            return False, f"{label.title()} keys do not match ({query_label} = {query_keys}, {target_label} = {target_keys})."
415
416        for key in sorted(query_keys):
417            query_value = query_dict[key]
418            target_value = target_dict[key]
419
420            if (query_value != target_value):
421                comparison = f"{query_label} = '{query_value}', {target_label} = '{target_value}'"
422                return False, f"{label.title()} '{key}' has a non-matching value ({comparison})."
423
424        return True, None
425
426    def get_url(self) -> str:
427        """ Get the URL path and anchor combined. """
428
429        url = self.url_path
430
431        if (self.url_anchor is not None):
432            url += ('#' + self.url_anchor)
433
434        return url
435
436    def match_response(self,
437            response: requests.Response,
438            override_body: typing.Union[str, None] = None,
439            headers_to_skip: typing.Union[typing.List[str], None] = None,
440            **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]:
441        """
442        Check if this exchange matches the given response.
443        If they match, `(True, None)` will be returned.
444        If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is.
445        """
446
447        if (headers_to_skip is None):
448            headers_to_skip = edq.net.settings.get_exchanges_ignore_headers()
449
450        response_body = override_body
451        if (response_body is None):
452            response_body = response.text
453
454        if (self.response_code != response.status_code):
455            return False, f"http status code does match (expected: {self.response_code}, actual: {response.status_code})"
456
457        expected_body = self.response_body
458        actual_body = None
459
460        if (self.json_body):
461            actual_body = response.json()
462
463            # Normalize the actual and expected bodies.
464
465            actual_body = edq.util.json.dumps(actual_body)
466
467            if (isinstance(expected_body, str)):
468                expected_body = edq.util.json.loads(expected_body)
469
470            expected_body = edq.util.json.dumps(expected_body)
471        else:
472            actual_body = response_body
473
474        if (self.response_body != actual_body):
475            body_hint = f"expected: '{self.response_body}', actual: '{actual_body}'"
476            return False, f"body does not match ({body_hint})"
477
478        response_headers = {key.lower().strip(): value for (key, value) in response.headers.items()}
479        match, hint = self._match_dict('header', response_headers, self.response_headers,
480                keys_to_skip = headers_to_skip,
481                query_label = 'response', target_label = 'exchange')
482
483        if (not match):
484            return False, hint
485
486        return True, None
487
488    def compute_relpath(self, http_exchange_extension: str = DEFAULT_HTTP_EXCHANGE_EXTENSION) -> str:
489        """ Create a consistent, semi-unique, and relative path for this exchange. """
490
491        url = self.get_url().strip()
492        parts = url.split('/')
493
494        if (url in ['', '/']):
495            filename = '_index_'
496            dirname = ''
497        else:
498            filename = parts[-1]
499
500            if (len(parts) > 1):
501                dirname = os.path.join(*parts[0:-1])
502            else:
503                dirname = ''
504
505        parameters = {}
506        for key in sorted(self.parameters.keys()):
507            parameters[key] = self.parameters[key]
508
509        # Treat files as params as well.
510        for file_info in self.files:
511            parameters[f"file-{file_info.name}"] = file_info.hash_content()
512
513        query = urllib.parse.urlencode(parameters)
514        if (query != ''):
515            # The query can get very long, so we may have to clip it.
516            query_text = edq.util.hash.clip_text(query, QUERY_CLIP_LENGTH)
517
518            # Note that the '?' is URL encoded.
519            filename += f"%3F{query_text}"
520
521        filename += f"_{self.method}{http_exchange_extension}"
522
523        return os.path.join(dirname, filename)
524
525    def to_dict(self,
526            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
527            ) -> typing.Dict[str, typing.Any]:
528        return vars(self)
529
530    @classmethod
531    def from_dict(cls,
532            data: typing.Dict[str, typing.Any],
533            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
534            ) -> typing.Any:
535        return HTTPExchange(**data)
536
537    @classmethod
538    def from_path(cls,
539            path: str,
540            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
541            ) -> 'HTTPExchange':
542        """
543        Load an exchange from a file.
544        This will also handle setting the exchanges source path (if specified) and resolving the exchange's paths.
545        """
546
547        if (context is None):
548            context = edq.util.serial.SerializationContext()
549
550        exchange = super().from_path(path, context)
551
552        set_source_path = edq.util.parse.soft_boolean(context.extra.get('set_source_path', True))
553        if (set_source_path is True):
554            exchange.source_path = os.path.abspath(path)
555
556        exchange.resolve_paths(os.path.abspath(os.path.dirname(path)))  # pylint: disable=no-member
557
558        return exchange
559
560    @classmethod
561    def from_response(cls,
562            response: requests.Response,
563            headers_to_skip: typing.Union[typing.List[str], None] = None,
564            params_to_skip: typing.Union[typing.List[str], None] = None,
565            allow_redirects: typing.Union[bool, None] = None,
566            clean_response_func: typing.Union[str, None] = None,
567            finalize_func: typing.Union[str, None] = None,
568            ) -> 'HTTPExchange':
569        """ Create a full exchange from a response. """
570
571        if (headers_to_skip is None):
572            headers_to_skip = edq.net.settings.get_exchanges_ignore_headers()
573
574        if (params_to_skip is None):
575            params_to_skip = []
576
577        if (clean_response_func is None):
578            clean_response_func = edq.net.settings.get_exchanges_clean_response_func()
579
580        if (finalize_func is None):
581            finalize_func = edq.net.settings.get_exchanges_finalize_func()
582
583        body = response.text
584
585        # Use a clean function (if one exists).
586        if (clean_response_func is not None):
587            # Make a copy of the response to avoid cleaning functions modifying it.
588            # Note that this is not a very complete solution, since we can't rely on the deep copy getting everything right.
589            response = copy.deepcopy(response)
590
591            modify_func = edq.util.pyimport.fetch(clean_response_func)
592            body = modify_func(response, body)
593
594        request_headers = {key.lower().strip(): value for (key, value) in response.request.headers.items()}
595        response_headers = {key.lower().strip(): value for (key, value) in response.headers.items()}
596
597        request_data, request_files = edq.net.util.parse_request_data(
598                response.request.url,
599                request_headers,
600                response.request.body)  # type: ignore[arg-type]
601
602        # Clean headers.
603        for key in headers_to_skip:
604            key = key.lower()
605
606            # Keep the location header if this is an allowed redirect.
607            if (allow_redirects and (key == 'location') and (300 <= response.status_code < 400)):
608                continue
609
610            request_headers.pop(key, None)
611            response_headers.pop(key, None)
612
613        # Clean parameters.
614        for key in params_to_skip:
615            request_data.pop(key, None)
616
617        files = [FileInfo(name = name, content = content) for (name, content) in request_files.items()]
618
619        data: typing.Dict[str, typing.Any] = {
620            'method': response.request.method,
621            'url': response.request.url,
622            'url_anchor': response.request.headers.get(edq.net.settings.ANCHOR_HEADER_KEY, None),
623            'parameters': request_data,
624            'files': files,
625            'headers': request_headers,
626            'response_code': response.status_code,
627            'response_headers': response_headers,
628            'response_body': body,
629            'response_modifier': clean_response_func,
630            'allow_redirects': allow_redirects,
631        }
632
633        exchange = HTTPExchange(**data)
634
635        # Use a finalize function (if one exists).
636        if (finalize_func is not None):
637            finalize_func_object = edq.util.pyimport.fetch(finalize_func)
638
639            exchange = finalize_func_object(exchange)
640            exchange.finalize = finalize_func
641
642        return exchange
643
644@typing.runtime_checkable
645class HTTPExchangeResponseCleanFunc(typing.Protocol):
646    """
647    A function that can be used to clean a response before creating an exchange.
648    """
649
650    def __call__(self,
651            response: requests.Response,
652            body: str,
653            ) -> str:
654        """
655        Take in a response and response body.
656        Clean the response in-place and return the new body.
657        """
658
659@typing.runtime_checkable
660class HTTPExchangeFinalizeFunc(typing.Protocol):
661    """
662    A function that can be used to finalize an exchange.
663    """
664
665    def __call__(self,
666            exchange: HTTPExchange
667            ) -> HTTPExchange:
668        """
669        Take in an exchange, finalize it, and pass back the finalized exchange (which may be different than the input one).
670        """
671
672@typing.runtime_checkable
673class HTTPExchangeComplete(typing.Protocol):
674    """
675    A function that can be called after a request has been made (and exchange constructed).
676    """
677
678    def __call__(self,
679            exchange: HTTPExchange
680            ) -> str:
681        """
682        Called after an HTTP exchange has been completed.
683        """
DEFAULT_HTTP_EXCHANGE_EXTENSION: str = '.httpex.json'
QUERY_CLIP_LENGTH: int = 100

If the filename of an HTTPExhange being saved is longer than this, then clip it.

ALLOWED_METHODS: List[str] = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT']

Allowed HTTP methods for an HTTPExchange.

class FileInfo(edq.util.serial.DictConverter):
 37class FileInfo(edq.util.serial.DictConverter):
 38    """ Store info about files used in HTTP exchanges. """
 39
 40    def __init__(self,
 41            path: typing.Union[str, None] = None,
 42            name: typing.Union[str, None] = None,
 43            content: typing.Union[str, bytes, None] = None,
 44            b64_encoded: bool = False,
 45            **kwargs: typing.Any) -> None:
 46        # Normalize the path from POSIX-style to the system's style.
 47        if (path is not None):
 48            path = str(pathlib.PurePath(pathlib.PurePosixPath(path)))
 49
 50        self.path: typing.Union[str, None] = path
 51        """ The on-disk path to a file. """
 52
 53        if ((name is None) and (self.path is not None)):
 54            name = os.path.basename(self.path)
 55
 56        if (name is None):
 57            raise ValueError("No name was provided for file.")
 58
 59        self.name: str = name
 60        """ The name for this file used in an HTTP request. """
 61
 62        self.content: typing.Union[str, bytes, None] = content
 63        """ The contents of this file. """
 64
 65        self.b64_encoded: bool = b64_encoded
 66        """ Whether the content is a string encoded in Base64. """
 67
 68        if ((self.path is None) and (self.content is None)):
 69            raise ValueError("File must have either path or content specified.")
 70
 71    def resolve_path(self, base_dir: str, load_file: bool = True) -> None:
 72        """ Resolve this path relative to the given base dir. """
 73
 74        if ((self.path is not None) and (not os.path.isabs(self.path))):
 75            self.path = os.path.abspath(os.path.join(base_dir, self.path))
 76
 77        if ((self.path is not None) and (self.content is None) and load_file):
 78            self.content = edq.util.dirent.read_file_bytes(self.path)
 79
 80    def hash_content(self) -> str:
 81        """
 82        Compute a hash for the content present.
 83        If no content is provided, use the path.
 84        """
 85
 86        hash_content = self.content
 87
 88        if (self.b64_encoded and isinstance(hash_content, str)):
 89            hash_content = edq.util.encoding.from_base64(hash_content)
 90
 91        if (hash_content is None):
 92            hash_content = self.path
 93
 94        return edq.util.hash.sha256_hex(hash_content)
 95
 96    def to_dict(self,
 97            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 98            ) -> typing.Dict[str, typing.Any]:
 99        data = vars(self).copy()
100
101        # JSON does not support raw bytes, so we will need to base64 encode any binary content.
102        if (isinstance(self.content, bytes)):
103            data['content'] = edq.util.encoding.to_base64(self.content)
104            data['b64_encoded'] = True
105
106        return data
107
108    @classmethod
109    def from_dict(cls,
110            data: typing.Dict[str, typing.Any],
111            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
112            ) -> typing.Any:
113        return FileInfo(**data)

Store info about files used in HTTP exchanges.

FileInfo( path: Optional[str] = None, name: Optional[str] = None, content: Union[str, bytes, NoneType] = None, b64_encoded: bool = False, **kwargs: Any)
40    def __init__(self,
41            path: typing.Union[str, None] = None,
42            name: typing.Union[str, None] = None,
43            content: typing.Union[str, bytes, None] = None,
44            b64_encoded: bool = False,
45            **kwargs: typing.Any) -> None:
46        # Normalize the path from POSIX-style to the system's style.
47        if (path is not None):
48            path = str(pathlib.PurePath(pathlib.PurePosixPath(path)))
49
50        self.path: typing.Union[str, None] = path
51        """ The on-disk path to a file. """
52
53        if ((name is None) and (self.path is not None)):
54            name = os.path.basename(self.path)
55
56        if (name is None):
57            raise ValueError("No name was provided for file.")
58
59        self.name: str = name
60        """ The name for this file used in an HTTP request. """
61
62        self.content: typing.Union[str, bytes, None] = content
63        """ The contents of this file. """
64
65        self.b64_encoded: bool = b64_encoded
66        """ Whether the content is a string encoded in Base64. """
67
68        if ((self.path is None) and (self.content is None)):
69            raise ValueError("File must have either path or content specified.")
path: Optional[str]

The on-disk path to a file.

name: str

The name for this file used in an HTTP request.

content: Union[str, bytes, NoneType]

The contents of this file.

b64_encoded: bool

Whether the content is a string encoded in Base64.

def resolve_path(self, base_dir: str, load_file: bool = True) -> None:
71    def resolve_path(self, base_dir: str, load_file: bool = True) -> None:
72        """ Resolve this path relative to the given base dir. """
73
74        if ((self.path is not None) and (not os.path.isabs(self.path))):
75            self.path = os.path.abspath(os.path.join(base_dir, self.path))
76
77        if ((self.path is not None) and (self.content is None) and load_file):
78            self.content = edq.util.dirent.read_file_bytes(self.path)

Resolve this path relative to the given base dir.

def hash_content(self) -> str:
80    def hash_content(self) -> str:
81        """
82        Compute a hash for the content present.
83        If no content is provided, use the path.
84        """
85
86        hash_content = self.content
87
88        if (self.b64_encoded and isinstance(hash_content, str)):
89            hash_content = edq.util.encoding.from_base64(hash_content)
90
91        if (hash_content is None):
92            hash_content = self.path
93
94        return edq.util.hash.sha256_hex(hash_content)

Compute a hash for the content present. If no content is provided, use the path.

def to_dict( self, context: Optional[edq.util.common.SerializationContext] = None) -> Dict[str, Any]:
 96    def to_dict(self,
 97            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 98            ) -> typing.Dict[str, typing.Any]:
 99        data = vars(self).copy()
100
101        # JSON does not support raw bytes, so we will need to base64 encode any binary content.
102        if (isinstance(self.content, bytes)):
103            data['content'] = edq.util.encoding.to_base64(self.content)
104            data['b64_encoded'] = True
105
106        return data

Return a dict that can be used to represent this object. If the dict is passed to from_dict(), an identical object should be reconstructed.

A general (but inefficient) implementation is provided by default.

@classmethod
def from_dict( cls, data: Dict[str, Any], context: Optional[edq.util.common.SerializationContext] = None) -> Any:
108    @classmethod
109    def from_dict(cls,
110            data: typing.Dict[str, typing.Any],
111            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
112            ) -> typing.Any:
113        return FileInfo(**data)

Return an instance of this subclass created using the given dict. If the dict came from to_dict(), the returned object should be equivalent to the original.

By default, this function just calls the class' constructor with the output of prep_init_data(). A child can override this or prep_init_data() depending on the functionality they want.

A general (but inefficient) implementation is provided by default. This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.

class HTTPExchange(edq.util.serial.DictConverter):
115class HTTPExchange(edq.util.serial.DictConverter):
116    """
117    The request and response making up a full HTTP exchange.
118    """
119
120    def __init__(self,
121            method: str = 'GET',
122            url: typing.Union[str, None] = None,
123            url_path: typing.Union[str, None] = None,
124            url_anchor: typing.Union[str, None] = None,
125            parameters: typing.Union[typing.Dict[str, typing.Any], None] = None,
126            files: typing.Union[typing.List[typing.Union[FileInfo, typing.Dict[str, typing.Any]]], None] = None,
127            headers: typing.Union[typing.Dict[str, typing.Any], None] = None,
128            allow_redirects: typing.Union[bool, None] = None,
129            response_code: int = http.HTTPStatus.OK,
130            response_headers: typing.Union[typing.Dict[str, typing.Any], None] = None,
131            json_body: typing.Union[bool, None] = None,
132            response_body: typing.Union[str, dict, list, None] = None,
133            source_path: typing.Union[str, None] = None,
134            response_modifier: typing.Union[str, None] = None,
135            finalize: typing.Union[str, None] = None,
136            extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
137            **kwargs: typing.Any) -> None:
138        method = str(method).upper()
139        if (method not in ALLOWED_METHODS):
140            raise ValueError(f"Got unknown/disallowed method: '{method}'.")
141
142        self.method: str = method
143        """ The HTTP method for this exchange. """
144
145        url_path, url_anchor, parameters = self._parse_url_components(url, url_path, url_anchor, parameters)
146
147        self.url_path: str = url_path
148        """
149        The path portion of the request URL.
150        Only the path (not domain, port, params, anchor, etc) should be included.
151        """
152
153        self.url_anchor: typing.Union[str, None] = url_anchor
154        """
155        The anchor portion of the request URL (if it exists).
156        """
157
158        self.parameters: typing.Dict[str, typing.Any] = parameters
159        """
160        The parameters/arguments for this request.
161        Parameters should be provided here and not encoded into URLs,
162        regardless of the request method.
163        With the exception of files, all parameters should be placed here.
164        """
165
166        if (files is None):
167            files = []
168
169        parsed_files = []
170        for file in files:
171            if (isinstance(file, FileInfo)):
172                parsed_files.append(file)
173            else:
174                parsed_files.append(FileInfo(**file))
175
176        self.files: typing.List[FileInfo] = parsed_files
177        """
178        A list of files to include in the request.
179        The files are represented as dicts with a
180        "path" (path to the file on disk) and "name" (the filename to send in the request) field.
181        These paths must be POSIX-style paths,
182        they will be converted to system-specific paths.
183        Once this exchange is ready for use, these paths should be resolved (and probably absolute).
184        However, when serialized these paths should probably be relative.
185        To reconcile this, resolve_paths() should be called before using this exchange.
186        """
187
188        if (headers is None):
189            headers = {}
190
191        self.headers: typing.Dict[str, typing.Any] = {key.lower().strip(): value for (key, value) in headers.items()}
192        """
193        Headers in the request.
194        All header keys are stored as lower case.
195        """
196
197        if (allow_redirects is None):
198            allow_redirects = True
199
200        self.allow_redirects: bool = allow_redirects
201        """ Follow redirects. """
202
203        self.response_code: int = response_code
204        """ The HTTP status code of the response. """
205
206        if (response_headers is None):
207            response_headers = {}
208
209        self.response_headers: typing.Dict[str, typing.Any] = {key.lower().strip(): value for (key, value) in response_headers.items()}
210        """
211        Headers in the response.
212        All header keys are stored as lower case.
213        """
214
215        if (json_body is None):
216            json_body = isinstance(response_body, (dict, list))
217
218        self.json_body: bool = json_body
219        """
220        Indicates that the response is JSON and should be converted to/from a string.
221        If the response body is passed in a dict/list and this is passed as None,
222        then this will be set as true.
223        """
224
225        if (self.json_body and isinstance(response_body, (dict, list))):
226            response_body = edq.util.json.dumps(response_body)
227
228        self.response_body: typing.Union[str, None] = response_body  # type: ignore[assignment]
229        """
230        The response that should be sent in this exchange.
231        """
232
233        self.response_modifier: typing.Union[str, None] = response_modifier
234        """
235        This function reference will be used to modify responses (in HTTPExchange.make_request() and HTTPExchange.from_response())
236        before sent back to the caller.
237        This reference must be importable via edq.util.pyimport.fetch().
238        """
239
240        self.finalize: typing.Union[str, None] = finalize
241        """
242        This function reference will be used to finalize echanges when created using from_response() before sent back to the caller.
243        This reference must be importable via edq.util.pyimport.fetch().
244        """
245
246        self.source_path: typing.Union[str, None] = source_path
247        """
248        The path that this exchange was loaded from (if it was loaded from a file).
249        This value should never be seriald, but can be useful for testing.
250        """
251
252        if (extra_options is None):
253            extra_options = {}
254
255        self.extra_options: typing.Dict[str, typing.Any] = extra_options.copy()
256        """
257        Additional options for this exchange.
258        This library will not use these options, but other's may.
259        kwargs will also be added to this.
260        """
261
262        self.extra_options.update(kwargs)
263
264    def _parse_url_components(self,
265            url: typing.Union[str, None] = None,
266            url_path: typing.Union[str, None] = None,
267            url_anchor: typing.Union[str, None] = None,
268            parameters: typing.Union[typing.Dict[str, typing.Any], None] = None,
269            ) -> typing.Tuple[str, typing.Union[str, None], typing.Dict[str, typing.Any]]:
270        """
271        Parse out all URL-based components from raw inputs.
272        The URL's path and anchor can either be supplied separately, or as part of the full given URL.
273        If content is present in both places, they much match (or an error will be raised).
274        Query parameters may be provided in the full URL,
275        but will be overwritten by any that are provided separately.
276        Any information from the URL aside from the path, anchor/fragment, and query will be ignored.
277        Note that path parameters (not query parameters) will be ignored.
278        The final url path, url anchor, and parameters will be returned.
279        """
280
281        # Do base initialization and cleanup.
282
283        if (url_path is not None):
284            url_path = url_path.strip()
285            if (url_path == ''):
286                url_path = ''
287            else:
288                url_path = url_path.lstrip('/')
289
290        if (url_anchor is not None):
291            url_anchor = url_anchor.strip()
292            if (url_anchor == ''):
293                url_anchor = None
294            else:
295                url_anchor = url_anchor.lstrip('#')
296
297        if (parameters is None):
298            parameters = {}
299
300        # Parse the URL (if present).
301
302        if ((url is not None) and (url.strip() != '')):
303            parts = urllib.parse.urlparse(url)
304
305            # Handle the path.
306
307            path = parts.path.lstrip('/')
308
309            if ((url_path is not None) and (url_path != path)):
310                raise ValueError(f"Mismatched URL paths where supplied implicitly ('{path}') and explicitly ('{url_path}').")
311
312            url_path = path
313
314            # Check the optional anchor/fragment.
315
316            if (parts.fragment != ''):
317                fragment = parts.fragment.lstrip('#')
318
319                if ((url_anchor is not None) and (url_anchor != fragment)):
320                    raise ValueError(f"Mismatched URL anchors where supplied implicitly ('{fragment}') and explicitly ('{url_anchor}').")
321
322                url_anchor = fragment
323
324            # Check for any parameters.
325
326            url_params = edq.net.util.parse_query_string(parts.query)
327            for (key, value) in url_params.items():
328                if (key not in parameters):
329                    parameters[key] = value
330
331        if (url_path is None):
332            raise ValueError('URL path cannot be empty, it must be explicitly set via `url_path`, or indirectly via `url`.')
333
334        # Sort parameter keys for consistency.
335        parameters = {key: parameters[key] for key in sorted(parameters.keys())}
336
337        return url_path, url_anchor, parameters
338
339    def resolve_paths(self, base_dir: str) -> None:
340        """ Resolve any paths relative to the given base dir. """
341
342        for file_info in self.files:
343            file_info.resolve_path(base_dir)
344
345    def match(self, query: 'HTTPExchange',
346            match_headers: bool = True,
347            headers_to_skip: typing.Union[typing.List[str], None] = None,
348            params_to_skip: typing.Union[typing.List[str], None] = None,
349            **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]:
350        """
351        Check if this exchange matches the query exchange.
352        If they match, `(True, None)` will be returned.
353        If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is.
354
355        Note that this is not an equality check,
356        as a query exchange is often missing the response components.
357        This method is often invoked the see if an incoming HTTP request (the query) matches an existing exchange.
358        """
359
360        if (query.method != self.method):
361            return False, f"HTTP method does not match (query = {query.method}, target = {self.method})."
362
363        if (query.url_path != self.url_path):
364            return False, f"URL path does not match (query = {query.url_path}, target = {self.url_path})."
365
366        if (query.url_anchor != self.url_anchor):
367            return False, f"URL anchor does not match (query = {query.url_anchor}, target = {self.url_anchor})."
368
369        if (headers_to_skip is None):
370            headers_to_skip = edq.net.settings.get_exchanges_ignore_headers()
371
372        if (params_to_skip is None):
373            params_to_skip = []
374
375        if (match_headers):
376            match, hint = self._match_dict('header', query.headers, self.headers, headers_to_skip)
377            if (not match):
378                return False, hint
379
380        match, hint = self._match_dict('parameter', query.parameters, self.parameters, params_to_skip)
381        if (not match):
382            return False, hint
383
384        # Check file names and hash contents.
385        query_filenames = {(file.name, file.hash_content()) for file in query.files}
386        target_filenames = {(file.name, file.hash_content()) for file in self.files}
387        if (query_filenames != target_filenames):
388            return False, f"File names do not match (query = {query_filenames}, target = {target_filenames})."
389
390        return True, None
391
392    def _match_dict(self,
393            label: str,
394            query_dict: typing.Dict[str, typing.Any],
395            target_dict: typing.Dict[str, typing.Any],
396            keys_to_skip: typing.Union[typing.List[str], None] = None,
397            query_label: str = 'query',
398            target_label: str = 'target',
399            normalize_key_case: bool = True,
400            ) -> typing.Tuple[bool, typing.Union[str, None]]:
401        """ A subcheck in match(), specifically for a dictionary. """
402
403        if (keys_to_skip is None):
404            keys_to_skip = []
405
406        if (normalize_key_case):
407            keys_to_skip = [key.lower() for key in keys_to_skip]
408            query_dict = {key.lower(): value for (key, value) in query_dict.items()}
409            target_dict = {key.lower(): value for (key, value) in target_dict.items()}
410
411        query_keys = set(query_dict.keys()) - set(keys_to_skip)
412        target_keys = set(target_dict.keys()) - set(keys_to_skip)
413
414        if (query_keys != target_keys):
415            return False, f"{label.title()} keys do not match ({query_label} = {query_keys}, {target_label} = {target_keys})."
416
417        for key in sorted(query_keys):
418            query_value = query_dict[key]
419            target_value = target_dict[key]
420
421            if (query_value != target_value):
422                comparison = f"{query_label} = '{query_value}', {target_label} = '{target_value}'"
423                return False, f"{label.title()} '{key}' has a non-matching value ({comparison})."
424
425        return True, None
426
427    def get_url(self) -> str:
428        """ Get the URL path and anchor combined. """
429
430        url = self.url_path
431
432        if (self.url_anchor is not None):
433            url += ('#' + self.url_anchor)
434
435        return url
436
437    def match_response(self,
438            response: requests.Response,
439            override_body: typing.Union[str, None] = None,
440            headers_to_skip: typing.Union[typing.List[str], None] = None,
441            **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]:
442        """
443        Check if this exchange matches the given response.
444        If they match, `(True, None)` will be returned.
445        If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is.
446        """
447
448        if (headers_to_skip is None):
449            headers_to_skip = edq.net.settings.get_exchanges_ignore_headers()
450
451        response_body = override_body
452        if (response_body is None):
453            response_body = response.text
454
455        if (self.response_code != response.status_code):
456            return False, f"http status code does match (expected: {self.response_code}, actual: {response.status_code})"
457
458        expected_body = self.response_body
459        actual_body = None
460
461        if (self.json_body):
462            actual_body = response.json()
463
464            # Normalize the actual and expected bodies.
465
466            actual_body = edq.util.json.dumps(actual_body)
467
468            if (isinstance(expected_body, str)):
469                expected_body = edq.util.json.loads(expected_body)
470
471            expected_body = edq.util.json.dumps(expected_body)
472        else:
473            actual_body = response_body
474
475        if (self.response_body != actual_body):
476            body_hint = f"expected: '{self.response_body}', actual: '{actual_body}'"
477            return False, f"body does not match ({body_hint})"
478
479        response_headers = {key.lower().strip(): value for (key, value) in response.headers.items()}
480        match, hint = self._match_dict('header', response_headers, self.response_headers,
481                keys_to_skip = headers_to_skip,
482                query_label = 'response', target_label = 'exchange')
483
484        if (not match):
485            return False, hint
486
487        return True, None
488
489    def compute_relpath(self, http_exchange_extension: str = DEFAULT_HTTP_EXCHANGE_EXTENSION) -> str:
490        """ Create a consistent, semi-unique, and relative path for this exchange. """
491
492        url = self.get_url().strip()
493        parts = url.split('/')
494
495        if (url in ['', '/']):
496            filename = '_index_'
497            dirname = ''
498        else:
499            filename = parts[-1]
500
501            if (len(parts) > 1):
502                dirname = os.path.join(*parts[0:-1])
503            else:
504                dirname = ''
505
506        parameters = {}
507        for key in sorted(self.parameters.keys()):
508            parameters[key] = self.parameters[key]
509
510        # Treat files as params as well.
511        for file_info in self.files:
512            parameters[f"file-{file_info.name}"] = file_info.hash_content()
513
514        query = urllib.parse.urlencode(parameters)
515        if (query != ''):
516            # The query can get very long, so we may have to clip it.
517            query_text = edq.util.hash.clip_text(query, QUERY_CLIP_LENGTH)
518
519            # Note that the '?' is URL encoded.
520            filename += f"%3F{query_text}"
521
522        filename += f"_{self.method}{http_exchange_extension}"
523
524        return os.path.join(dirname, filename)
525
526    def to_dict(self,
527            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
528            ) -> typing.Dict[str, typing.Any]:
529        return vars(self)
530
531    @classmethod
532    def from_dict(cls,
533            data: typing.Dict[str, typing.Any],
534            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
535            ) -> typing.Any:
536        return HTTPExchange(**data)
537
538    @classmethod
539    def from_path(cls,
540            path: str,
541            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
542            ) -> 'HTTPExchange':
543        """
544        Load an exchange from a file.
545        This will also handle setting the exchanges source path (if specified) and resolving the exchange's paths.
546        """
547
548        if (context is None):
549            context = edq.util.serial.SerializationContext()
550
551        exchange = super().from_path(path, context)
552
553        set_source_path = edq.util.parse.soft_boolean(context.extra.get('set_source_path', True))
554        if (set_source_path is True):
555            exchange.source_path = os.path.abspath(path)
556
557        exchange.resolve_paths(os.path.abspath(os.path.dirname(path)))  # pylint: disable=no-member
558
559        return exchange
560
561    @classmethod
562    def from_response(cls,
563            response: requests.Response,
564            headers_to_skip: typing.Union[typing.List[str], None] = None,
565            params_to_skip: typing.Union[typing.List[str], None] = None,
566            allow_redirects: typing.Union[bool, None] = None,
567            clean_response_func: typing.Union[str, None] = None,
568            finalize_func: typing.Union[str, None] = None,
569            ) -> 'HTTPExchange':
570        """ Create a full exchange from a response. """
571
572        if (headers_to_skip is None):
573            headers_to_skip = edq.net.settings.get_exchanges_ignore_headers()
574
575        if (params_to_skip is None):
576            params_to_skip = []
577
578        if (clean_response_func is None):
579            clean_response_func = edq.net.settings.get_exchanges_clean_response_func()
580
581        if (finalize_func is None):
582            finalize_func = edq.net.settings.get_exchanges_finalize_func()
583
584        body = response.text
585
586        # Use a clean function (if one exists).
587        if (clean_response_func is not None):
588            # Make a copy of the response to avoid cleaning functions modifying it.
589            # Note that this is not a very complete solution, since we can't rely on the deep copy getting everything right.
590            response = copy.deepcopy(response)
591
592            modify_func = edq.util.pyimport.fetch(clean_response_func)
593            body = modify_func(response, body)
594
595        request_headers = {key.lower().strip(): value for (key, value) in response.request.headers.items()}
596        response_headers = {key.lower().strip(): value for (key, value) in response.headers.items()}
597
598        request_data, request_files = edq.net.util.parse_request_data(
599                response.request.url,
600                request_headers,
601                response.request.body)  # type: ignore[arg-type]
602
603        # Clean headers.
604        for key in headers_to_skip:
605            key = key.lower()
606
607            # Keep the location header if this is an allowed redirect.
608            if (allow_redirects and (key == 'location') and (300 <= response.status_code < 400)):
609                continue
610
611            request_headers.pop(key, None)
612            response_headers.pop(key, None)
613
614        # Clean parameters.
615        for key in params_to_skip:
616            request_data.pop(key, None)
617
618        files = [FileInfo(name = name, content = content) for (name, content) in request_files.items()]
619
620        data: typing.Dict[str, typing.Any] = {
621            'method': response.request.method,
622            'url': response.request.url,
623            'url_anchor': response.request.headers.get(edq.net.settings.ANCHOR_HEADER_KEY, None),
624            'parameters': request_data,
625            'files': files,
626            'headers': request_headers,
627            'response_code': response.status_code,
628            'response_headers': response_headers,
629            'response_body': body,
630            'response_modifier': clean_response_func,
631            'allow_redirects': allow_redirects,
632        }
633
634        exchange = HTTPExchange(**data)
635
636        # Use a finalize function (if one exists).
637        if (finalize_func is not None):
638            finalize_func_object = edq.util.pyimport.fetch(finalize_func)
639
640            exchange = finalize_func_object(exchange)
641            exchange.finalize = finalize_func
642
643        return exchange

The request and response making up a full HTTP exchange.

HTTPExchange( method: str = 'GET', url: Optional[str] = None, url_path: Optional[str] = None, url_anchor: Optional[str] = None, parameters: Optional[Dict[str, Any]] = None, files: Optional[List[Union[FileInfo, Dict[str, Any]]]] = None, headers: Optional[Dict[str, Any]] = None, allow_redirects: Optional[bool] = None, response_code: int = <HTTPStatus.OK: 200>, response_headers: Optional[Dict[str, Any]] = None, json_body: Optional[bool] = None, response_body: Union[str, dict, list, NoneType] = None, source_path: Optional[str] = None, response_modifier: Optional[str] = None, finalize: Optional[str] = None, extra_options: Optional[Dict[str, Any]] = None, **kwargs: Any)
120    def __init__(self,
121            method: str = 'GET',
122            url: typing.Union[str, None] = None,
123            url_path: typing.Union[str, None] = None,
124            url_anchor: typing.Union[str, None] = None,
125            parameters: typing.Union[typing.Dict[str, typing.Any], None] = None,
126            files: typing.Union[typing.List[typing.Union[FileInfo, typing.Dict[str, typing.Any]]], None] = None,
127            headers: typing.Union[typing.Dict[str, typing.Any], None] = None,
128            allow_redirects: typing.Union[bool, None] = None,
129            response_code: int = http.HTTPStatus.OK,
130            response_headers: typing.Union[typing.Dict[str, typing.Any], None] = None,
131            json_body: typing.Union[bool, None] = None,
132            response_body: typing.Union[str, dict, list, None] = None,
133            source_path: typing.Union[str, None] = None,
134            response_modifier: typing.Union[str, None] = None,
135            finalize: typing.Union[str, None] = None,
136            extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
137            **kwargs: typing.Any) -> None:
138        method = str(method).upper()
139        if (method not in ALLOWED_METHODS):
140            raise ValueError(f"Got unknown/disallowed method: '{method}'.")
141
142        self.method: str = method
143        """ The HTTP method for this exchange. """
144
145        url_path, url_anchor, parameters = self._parse_url_components(url, url_path, url_anchor, parameters)
146
147        self.url_path: str = url_path
148        """
149        The path portion of the request URL.
150        Only the path (not domain, port, params, anchor, etc) should be included.
151        """
152
153        self.url_anchor: typing.Union[str, None] = url_anchor
154        """
155        The anchor portion of the request URL (if it exists).
156        """
157
158        self.parameters: typing.Dict[str, typing.Any] = parameters
159        """
160        The parameters/arguments for this request.
161        Parameters should be provided here and not encoded into URLs,
162        regardless of the request method.
163        With the exception of files, all parameters should be placed here.
164        """
165
166        if (files is None):
167            files = []
168
169        parsed_files = []
170        for file in files:
171            if (isinstance(file, FileInfo)):
172                parsed_files.append(file)
173            else:
174                parsed_files.append(FileInfo(**file))
175
176        self.files: typing.List[FileInfo] = parsed_files
177        """
178        A list of files to include in the request.
179        The files are represented as dicts with a
180        "path" (path to the file on disk) and "name" (the filename to send in the request) field.
181        These paths must be POSIX-style paths,
182        they will be converted to system-specific paths.
183        Once this exchange is ready for use, these paths should be resolved (and probably absolute).
184        However, when serialized these paths should probably be relative.
185        To reconcile this, resolve_paths() should be called before using this exchange.
186        """
187
188        if (headers is None):
189            headers = {}
190
191        self.headers: typing.Dict[str, typing.Any] = {key.lower().strip(): value for (key, value) in headers.items()}
192        """
193        Headers in the request.
194        All header keys are stored as lower case.
195        """
196
197        if (allow_redirects is None):
198            allow_redirects = True
199
200        self.allow_redirects: bool = allow_redirects
201        """ Follow redirects. """
202
203        self.response_code: int = response_code
204        """ The HTTP status code of the response. """
205
206        if (response_headers is None):
207            response_headers = {}
208
209        self.response_headers: typing.Dict[str, typing.Any] = {key.lower().strip(): value for (key, value) in response_headers.items()}
210        """
211        Headers in the response.
212        All header keys are stored as lower case.
213        """
214
215        if (json_body is None):
216            json_body = isinstance(response_body, (dict, list))
217
218        self.json_body: bool = json_body
219        """
220        Indicates that the response is JSON and should be converted to/from a string.
221        If the response body is passed in a dict/list and this is passed as None,
222        then this will be set as true.
223        """
224
225        if (self.json_body and isinstance(response_body, (dict, list))):
226            response_body = edq.util.json.dumps(response_body)
227
228        self.response_body: typing.Union[str, None] = response_body  # type: ignore[assignment]
229        """
230        The response that should be sent in this exchange.
231        """
232
233        self.response_modifier: typing.Union[str, None] = response_modifier
234        """
235        This function reference will be used to modify responses (in HTTPExchange.make_request() and HTTPExchange.from_response())
236        before sent back to the caller.
237        This reference must be importable via edq.util.pyimport.fetch().
238        """
239
240        self.finalize: typing.Union[str, None] = finalize
241        """
242        This function reference will be used to finalize echanges when created using from_response() before sent back to the caller.
243        This reference must be importable via edq.util.pyimport.fetch().
244        """
245
246        self.source_path: typing.Union[str, None] = source_path
247        """
248        The path that this exchange was loaded from (if it was loaded from a file).
249        This value should never be seriald, but can be useful for testing.
250        """
251
252        if (extra_options is None):
253            extra_options = {}
254
255        self.extra_options: typing.Dict[str, typing.Any] = extra_options.copy()
256        """
257        Additional options for this exchange.
258        This library will not use these options, but other's may.
259        kwargs will also be added to this.
260        """
261
262        self.extra_options.update(kwargs)
method: str

The HTTP method for this exchange.

url_path: str

The path portion of the request URL. Only the path (not domain, port, params, anchor, etc) should be included.

url_anchor: Optional[str]

The anchor portion of the request URL (if it exists).

parameters: Dict[str, Any]

The parameters/arguments for this request. Parameters should be provided here and not encoded into URLs, regardless of the request method. With the exception of files, all parameters should be placed here.

files: List[FileInfo]

A list of files to include in the request. The files are represented as dicts with a "path" (path to the file on disk) and "name" (the filename to send in the request) field. These paths must be POSIX-style paths, they will be converted to system-specific paths. Once this exchange is ready for use, these paths should be resolved (and probably absolute). However, when serialized these paths should probably be relative. To reconcile this, resolve_paths() should be called before using this exchange.

headers: Dict[str, Any]

Headers in the request. All header keys are stored as lower case.

allow_redirects: bool

Follow redirects.

response_code: int

The HTTP status code of the response.

response_headers: Dict[str, Any]

Headers in the response. All header keys are stored as lower case.

json_body: bool

Indicates that the response is JSON and should be converted to/from a string. If the response body is passed in a dict/list and this is passed as None, then this will be set as true.

response_body: Optional[str]

The response that should be sent in this exchange.

response_modifier: Optional[str]

This function reference will be used to modify responses (in HTTPExchange.make_request() and HTTPExchange.from_response()) before sent back to the caller. This reference must be importable via edq.util.pyimport.fetch().

finalize: Optional[str]

This function reference will be used to finalize echanges when created using from_response() before sent back to the caller. This reference must be importable via edq.util.pyimport.fetch().

source_path: Optional[str]

The path that this exchange was loaded from (if it was loaded from a file). This value should never be seriald, but can be useful for testing.

extra_options: Dict[str, Any]

Additional options for this exchange. This library will not use these options, but other's may. kwargs will also be added to this.

def resolve_paths(self, base_dir: str) -> None:
339    def resolve_paths(self, base_dir: str) -> None:
340        """ Resolve any paths relative to the given base dir. """
341
342        for file_info in self.files:
343            file_info.resolve_path(base_dir)

Resolve any paths relative to the given base dir.

def match( self, query: HTTPExchange, match_headers: bool = True, headers_to_skip: Optional[List[str]] = None, params_to_skip: Optional[List[str]] = None, **kwargs: Any) -> Tuple[bool, Optional[str]]:
345    def match(self, query: 'HTTPExchange',
346            match_headers: bool = True,
347            headers_to_skip: typing.Union[typing.List[str], None] = None,
348            params_to_skip: typing.Union[typing.List[str], None] = None,
349            **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]:
350        """
351        Check if this exchange matches the query exchange.
352        If they match, `(True, None)` will be returned.
353        If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is.
354
355        Note that this is not an equality check,
356        as a query exchange is often missing the response components.
357        This method is often invoked the see if an incoming HTTP request (the query) matches an existing exchange.
358        """
359
360        if (query.method != self.method):
361            return False, f"HTTP method does not match (query = {query.method}, target = {self.method})."
362
363        if (query.url_path != self.url_path):
364            return False, f"URL path does not match (query = {query.url_path}, target = {self.url_path})."
365
366        if (query.url_anchor != self.url_anchor):
367            return False, f"URL anchor does not match (query = {query.url_anchor}, target = {self.url_anchor})."
368
369        if (headers_to_skip is None):
370            headers_to_skip = edq.net.settings.get_exchanges_ignore_headers()
371
372        if (params_to_skip is None):
373            params_to_skip = []
374
375        if (match_headers):
376            match, hint = self._match_dict('header', query.headers, self.headers, headers_to_skip)
377            if (not match):
378                return False, hint
379
380        match, hint = self._match_dict('parameter', query.parameters, self.parameters, params_to_skip)
381        if (not match):
382            return False, hint
383
384        # Check file names and hash contents.
385        query_filenames = {(file.name, file.hash_content()) for file in query.files}
386        target_filenames = {(file.name, file.hash_content()) for file in self.files}
387        if (query_filenames != target_filenames):
388            return False, f"File names do not match (query = {query_filenames}, target = {target_filenames})."
389
390        return True, None

Check if this exchange matches the query exchange. If they match, (True, None) will be returned. If they do not match, (False, <hint>) will be returned, where <hint> points to where the mismatch is.

Note that this is not an equality check, as a query exchange is often missing the response components. This method is often invoked the see if an incoming HTTP request (the query) matches an existing exchange.

def get_url(self) -> str:
427    def get_url(self) -> str:
428        """ Get the URL path and anchor combined. """
429
430        url = self.url_path
431
432        if (self.url_anchor is not None):
433            url += ('#' + self.url_anchor)
434
435        return url

Get the URL path and anchor combined.

def match_response( self, response: requests.models.Response, override_body: Optional[str] = None, headers_to_skip: Optional[List[str]] = None, **kwargs: Any) -> Tuple[bool, Optional[str]]:
437    def match_response(self,
438            response: requests.Response,
439            override_body: typing.Union[str, None] = None,
440            headers_to_skip: typing.Union[typing.List[str], None] = None,
441            **kwargs: typing.Any) -> typing.Tuple[bool, typing.Union[str, None]]:
442        """
443        Check if this exchange matches the given response.
444        If they match, `(True, None)` will be returned.
445        If they do not match, `(False, <hint>)` will be returned, where `<hint>` points to where the mismatch is.
446        """
447
448        if (headers_to_skip is None):
449            headers_to_skip = edq.net.settings.get_exchanges_ignore_headers()
450
451        response_body = override_body
452        if (response_body is None):
453            response_body = response.text
454
455        if (self.response_code != response.status_code):
456            return False, f"http status code does match (expected: {self.response_code}, actual: {response.status_code})"
457
458        expected_body = self.response_body
459        actual_body = None
460
461        if (self.json_body):
462            actual_body = response.json()
463
464            # Normalize the actual and expected bodies.
465
466            actual_body = edq.util.json.dumps(actual_body)
467
468            if (isinstance(expected_body, str)):
469                expected_body = edq.util.json.loads(expected_body)
470
471            expected_body = edq.util.json.dumps(expected_body)
472        else:
473            actual_body = response_body
474
475        if (self.response_body != actual_body):
476            body_hint = f"expected: '{self.response_body}', actual: '{actual_body}'"
477            return False, f"body does not match ({body_hint})"
478
479        response_headers = {key.lower().strip(): value for (key, value) in response.headers.items()}
480        match, hint = self._match_dict('header', response_headers, self.response_headers,
481                keys_to_skip = headers_to_skip,
482                query_label = 'response', target_label = 'exchange')
483
484        if (not match):
485            return False, hint
486
487        return True, None

Check if this exchange matches the given response. If they match, (True, None) will be returned. If they do not match, (False, <hint>) will be returned, where <hint> points to where the mismatch is.

def compute_relpath(self, http_exchange_extension: str = '.httpex.json') -> str:
489    def compute_relpath(self, http_exchange_extension: str = DEFAULT_HTTP_EXCHANGE_EXTENSION) -> str:
490        """ Create a consistent, semi-unique, and relative path for this exchange. """
491
492        url = self.get_url().strip()
493        parts = url.split('/')
494
495        if (url in ['', '/']):
496            filename = '_index_'
497            dirname = ''
498        else:
499            filename = parts[-1]
500
501            if (len(parts) > 1):
502                dirname = os.path.join(*parts[0:-1])
503            else:
504                dirname = ''
505
506        parameters = {}
507        for key in sorted(self.parameters.keys()):
508            parameters[key] = self.parameters[key]
509
510        # Treat files as params as well.
511        for file_info in self.files:
512            parameters[f"file-{file_info.name}"] = file_info.hash_content()
513
514        query = urllib.parse.urlencode(parameters)
515        if (query != ''):
516            # The query can get very long, so we may have to clip it.
517            query_text = edq.util.hash.clip_text(query, QUERY_CLIP_LENGTH)
518
519            # Note that the '?' is URL encoded.
520            filename += f"%3F{query_text}"
521
522        filename += f"_{self.method}{http_exchange_extension}"
523
524        return os.path.join(dirname, filename)

Create a consistent, semi-unique, and relative path for this exchange.

def to_dict( self, context: Optional[edq.util.common.SerializationContext] = None) -> Dict[str, Any]:
526    def to_dict(self,
527            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
528            ) -> typing.Dict[str, typing.Any]:
529        return vars(self)

Return a dict that can be used to represent this object. If the dict is passed to from_dict(), an identical object should be reconstructed.

A general (but inefficient) implementation is provided by default.

@classmethod
def from_dict( cls, data: Dict[str, Any], context: Optional[edq.util.common.SerializationContext] = None) -> Any:
531    @classmethod
532    def from_dict(cls,
533            data: typing.Dict[str, typing.Any],
534            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
535            ) -> typing.Any:
536        return HTTPExchange(**data)

Return an instance of this subclass created using the given dict. If the dict came from to_dict(), the returned object should be equivalent to the original.

By default, this function just calls the class' constructor with the output of prep_init_data(). A child can override this or prep_init_data() depending on the functionality they want.

A general (but inefficient) implementation is provided by default. This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.

@classmethod
def from_path( cls, path: str, context: Optional[edq.util.common.SerializationContext] = None) -> HTTPExchange:
538    @classmethod
539    def from_path(cls,
540            path: str,
541            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
542            ) -> 'HTTPExchange':
543        """
544        Load an exchange from a file.
545        This will also handle setting the exchanges source path (if specified) and resolving the exchange's paths.
546        """
547
548        if (context is None):
549            context = edq.util.serial.SerializationContext()
550
551        exchange = super().from_path(path, context)
552
553        set_source_path = edq.util.parse.soft_boolean(context.extra.get('set_source_path', True))
554        if (set_source_path is True):
555            exchange.source_path = os.path.abspath(path)
556
557        exchange.resolve_paths(os.path.abspath(os.path.dirname(path)))  # pylint: disable=no-member
558
559        return exchange

Load an exchange from a file. This will also handle setting the exchanges source path (if specified) and resolving the exchange's paths.

@classmethod
def from_response( cls, response: requests.models.Response, headers_to_skip: Optional[List[str]] = None, params_to_skip: Optional[List[str]] = None, allow_redirects: Optional[bool] = None, clean_response_func: Optional[str] = None, finalize_func: Optional[str] = None) -> HTTPExchange:
561    @classmethod
562    def from_response(cls,
563            response: requests.Response,
564            headers_to_skip: typing.Union[typing.List[str], None] = None,
565            params_to_skip: typing.Union[typing.List[str], None] = None,
566            allow_redirects: typing.Union[bool, None] = None,
567            clean_response_func: typing.Union[str, None] = None,
568            finalize_func: typing.Union[str, None] = None,
569            ) -> 'HTTPExchange':
570        """ Create a full exchange from a response. """
571
572        if (headers_to_skip is None):
573            headers_to_skip = edq.net.settings.get_exchanges_ignore_headers()
574
575        if (params_to_skip is None):
576            params_to_skip = []
577
578        if (clean_response_func is None):
579            clean_response_func = edq.net.settings.get_exchanges_clean_response_func()
580
581        if (finalize_func is None):
582            finalize_func = edq.net.settings.get_exchanges_finalize_func()
583
584        body = response.text
585
586        # Use a clean function (if one exists).
587        if (clean_response_func is not None):
588            # Make a copy of the response to avoid cleaning functions modifying it.
589            # Note that this is not a very complete solution, since we can't rely on the deep copy getting everything right.
590            response = copy.deepcopy(response)
591
592            modify_func = edq.util.pyimport.fetch(clean_response_func)
593            body = modify_func(response, body)
594
595        request_headers = {key.lower().strip(): value for (key, value) in response.request.headers.items()}
596        response_headers = {key.lower().strip(): value for (key, value) in response.headers.items()}
597
598        request_data, request_files = edq.net.util.parse_request_data(
599                response.request.url,
600                request_headers,
601                response.request.body)  # type: ignore[arg-type]
602
603        # Clean headers.
604        for key in headers_to_skip:
605            key = key.lower()
606
607            # Keep the location header if this is an allowed redirect.
608            if (allow_redirects and (key == 'location') and (300 <= response.status_code < 400)):
609                continue
610
611            request_headers.pop(key, None)
612            response_headers.pop(key, None)
613
614        # Clean parameters.
615        for key in params_to_skip:
616            request_data.pop(key, None)
617
618        files = [FileInfo(name = name, content = content) for (name, content) in request_files.items()]
619
620        data: typing.Dict[str, typing.Any] = {
621            'method': response.request.method,
622            'url': response.request.url,
623            'url_anchor': response.request.headers.get(edq.net.settings.ANCHOR_HEADER_KEY, None),
624            'parameters': request_data,
625            'files': files,
626            'headers': request_headers,
627            'response_code': response.status_code,
628            'response_headers': response_headers,
629            'response_body': body,
630            'response_modifier': clean_response_func,
631            'allow_redirects': allow_redirects,
632        }
633
634        exchange = HTTPExchange(**data)
635
636        # Use a finalize function (if one exists).
637        if (finalize_func is not None):
638            finalize_func_object = edq.util.pyimport.fetch(finalize_func)
639
640            exchange = finalize_func_object(exchange)
641            exchange.finalize = finalize_func
642
643        return exchange

Create a full exchange from a response.

@typing.runtime_checkable
class HTTPExchangeResponseCleanFunc(typing.Protocol):
645@typing.runtime_checkable
646class HTTPExchangeResponseCleanFunc(typing.Protocol):
647    """
648    A function that can be used to clean a response before creating an exchange.
649    """
650
651    def __call__(self,
652            response: requests.Response,
653            body: str,
654            ) -> str:
655        """
656        Take in a response and response body.
657        Clean the response in-place and return the new body.
658        """

A function that can be used to clean a response before creating an exchange.

HTTPExchangeResponseCleanFunc(*args, **kwargs)
1953def _no_init_or_replace_init(self, *args, **kwargs):
1954    cls = type(self)
1955
1956    if cls._is_protocol:
1957        raise TypeError('Protocols cannot be instantiated')
1958
1959    # Already using a custom `__init__`. No need to calculate correct
1960    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1961    if cls.__init__ is not _no_init_or_replace_init:
1962        return
1963
1964    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1965    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1966    # searches for a proper new `__init__` in the MRO. The new `__init__`
1967    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1968    # instantiation of the protocol subclass will thus use the new
1969    # `__init__` and no longer call `_no_init_or_replace_init`.
1970    for base in cls.__mro__:
1971        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1972        if init is not _no_init_or_replace_init:
1973            cls.__init__ = init
1974            break
1975    else:
1976        # should not happen
1977        cls.__init__ = object.__init__
1978
1979    cls.__init__(self, *args, **kwargs)
@typing.runtime_checkable
class HTTPExchangeFinalizeFunc(typing.Protocol):
660@typing.runtime_checkable
661class HTTPExchangeFinalizeFunc(typing.Protocol):
662    """
663    A function that can be used to finalize an exchange.
664    """
665
666    def __call__(self,
667            exchange: HTTPExchange
668            ) -> HTTPExchange:
669        """
670        Take in an exchange, finalize it, and pass back the finalized exchange (which may be different than the input one).
671        """

A function that can be used to finalize an exchange.

HTTPExchangeFinalizeFunc(*args, **kwargs)
1953def _no_init_or_replace_init(self, *args, **kwargs):
1954    cls = type(self)
1955
1956    if cls._is_protocol:
1957        raise TypeError('Protocols cannot be instantiated')
1958
1959    # Already using a custom `__init__`. No need to calculate correct
1960    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1961    if cls.__init__ is not _no_init_or_replace_init:
1962        return
1963
1964    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1965    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1966    # searches for a proper new `__init__` in the MRO. The new `__init__`
1967    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1968    # instantiation of the protocol subclass will thus use the new
1969    # `__init__` and no longer call `_no_init_or_replace_init`.
1970    for base in cls.__mro__:
1971        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1972        if init is not _no_init_or_replace_init:
1973            cls.__init__ = init
1974            break
1975    else:
1976        # should not happen
1977        cls.__init__ = object.__init__
1978
1979    cls.__init__(self, *args, **kwargs)
@typing.runtime_checkable
class HTTPExchangeComplete(typing.Protocol):
673@typing.runtime_checkable
674class HTTPExchangeComplete(typing.Protocol):
675    """
676    A function that can be called after a request has been made (and exchange constructed).
677    """
678
679    def __call__(self,
680            exchange: HTTPExchange
681            ) -> str:
682        """
683        Called after an HTTP exchange has been completed.
684        """

A function that can be called after a request has been made (and exchange constructed).

HTTPExchangeComplete(*args, **kwargs)
1953def _no_init_or_replace_init(self, *args, **kwargs):
1954    cls = type(self)
1955
1956    if cls._is_protocol:
1957        raise TypeError('Protocols cannot be instantiated')
1958
1959    # Already using a custom `__init__`. No need to calculate correct
1960    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1961    if cls.__init__ is not _no_init_or_replace_init:
1962        return
1963
1964    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1965    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1966    # searches for a proper new `__init__` in the MRO. The new `__init__`
1967    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1968    # instantiation of the protocol subclass will thus use the new
1969    # `__init__` and no longer call `_no_init_or_replace_init`.
1970    for base in cls.__mro__:
1971        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1972        if init is not _no_init_or_replace_init:
1973            cls.__init__ = init
1974            break
1975    else:
1976        # should not happen
1977        cls.__init__ = object.__init__
1978
1979    cls.__init__(self, *args, **kwargs)