edq.net.util

General utilities for working with HTTP items.

It is generally assumed that header keys will always be lower case. Or at least, the value will be accessible when looked up with a lower case key, e.g. if the header dict is case insensitive.

  1"""
  2General utilities for working with HTTP items.
  3
  4It is generally assumed that header keys will always be lower case.
  5Or at least, the value will be accessible when looked up with a lower case key,
  6e.g. if the header dict is case insensitive.
  7"""
  8
  9import email.message
 10import errno
 11import io
 12import socket
 13import time
 14import typing
 15import urllib.parse
 16
 17import requests_toolbelt.multipart.decoder
 18
 19import edq.util.dirent
 20
 21DEFAULT_START_PORT: int = 30000
 22DEFAULT_END_PORT: int = 40000
 23DEFAULT_PORT_SEARCH_WAIT_SEC: float = 0.01
 24
 25def find_open_port(
 26        start_port: int = DEFAULT_START_PORT,
 27        end_port: int = DEFAULT_END_PORT,
 28        wait_time: float = DEFAULT_PORT_SEARCH_WAIT_SEC,
 29        ) -> int:
 30    """
 31    Find an open port on this machine within the given range (inclusive).
 32    If no open port is found, an error is raised.
 33    """
 34
 35    for port in range(start_port, end_port + 1):
 36        try:
 37            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 38            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
 39            sock.bind(('127.0.0.1', port))
 40
 41            # Explicitly close the port and wait a short amount of time for the port to clear.
 42            # This should not be required because of the socket option above,
 43            # but the cost is small.
 44            sock.close()
 45            time.sleep(DEFAULT_PORT_SEARCH_WAIT_SEC)
 46
 47            return port
 48        except socket.error as ex:
 49            sock.close()
 50
 51            if (ex.errno == errno.EADDRINUSE):
 52                continue
 53
 54            # Unknown error.
 55            raise ex
 56
 57    raise ValueError(f"Could not find open port in [{start_port}, {end_port}].")
 58
 59def parse_request_data(
 60        url: typing.Union[str, None],
 61        headers: typing.Union[email.message.Message, typing.Dict[str, typing.Any]],
 62        body: typing.Union[bytes, str, io.BufferedIOBase],
 63        ) -> typing.Tuple[typing.Dict[str, typing.Any], typing.Dict[str, bytes]]:
 64    """ Parse data and files from an HTTP request URL and body. """
 65
 66    # Parse data from the request body.
 67    request_data, request_files = parse_request_body_data(headers, body)
 68
 69    # Parse parameters from the URL.
 70    if (url is not None):
 71        url_parts = urllib.parse.urlparse(url)
 72        request_data.update(parse_query_string(url_parts.query))
 73
 74    return request_data, request_files
 75
 76def parse_request_body_data(
 77        headers: typing.Union[email.message.Message, typing.Dict[str, typing.Any]],
 78        body: typing.Union[bytes, str, io.BufferedIOBase],
 79        ) -> typing.Tuple[typing.Dict[str, typing.Any], typing.Dict[str, bytes]]:
 80    """ Parse data and files from an HTTP request body. """
 81
 82    data: typing.Dict[str, typing.Any] = {}
 83    files: typing.Dict[str, bytes] = {}
 84
 85    length = int(headers.get('content-length', 0))
 86    if (length == 0):
 87        return data, files
 88
 89    if (isinstance(body, io.BufferedIOBase)):
 90        raw_content = body.read(length)
 91    elif (isinstance(body, str)):
 92        raw_content = body.encode(edq.util.dirent.DEFAULT_ENCODING)
 93    else:
 94        raw_content = body
 95
 96    content_type = headers.get('content-type', '').lower()
 97
 98    if (content_type.startswith('text/plain')):
 99        data[''] = raw_content.decode(edq.util.dirent.DEFAULT_ENCODING).strip()
100        return data, files
101
102    if (content_type in ['', 'application/x-www-form-urlencoded']):
103        data = parse_query_string(raw_content.decode(edq.util.dirent.DEFAULT_ENCODING).strip())
104        return data, files
105
106    if (content_type.startswith('multipart/form-data')):
107        decoder = requests_toolbelt.multipart.decoder.MultipartDecoder(
108            raw_content, content_type, encoding = edq.util.dirent.DEFAULT_ENCODING)
109
110        for multipart_section in decoder.parts:
111            values = parse_content_dispositions(multipart_section.headers)
112
113            name = values.get('name', None)
114            if (name is None):
115                raise ValueError("Could not find name for multipart section.")
116
117            # Look for a "filename" field to indicate a multipart section is a file.
118            # The file's desired name is still in "name", but an alternate name is in "filename".
119            if ('filename' in values):
120                filename = values.get('name', '')
121                if (filename == ''):
122                    raise ValueError("Unable to find filename for multipart section.")
123
124                files[filename] = multipart_section.content
125            else:
126                # Normal Parameter
127                data[name] = multipart_section.text
128
129        return data, files
130
131    raise ValueError(f"Unknown content type: '{content_type}'.")
132
133def parse_content_dispositions(headers: typing.Union[email.message.Message, typing.Dict[str, typing.Any]]) -> typing.Dict[str, typing.Any]:
134    """ Parse a request's content dispositions from headers. """
135
136    values = {}
137    for (key, value) in headers.items():
138        if (isinstance(key, bytes)):
139            key = key.decode(edq.util.dirent.DEFAULT_ENCODING)
140
141        if (isinstance(value, bytes)):
142            value = value.decode(edq.util.dirent.DEFAULT_ENCODING)
143
144        key = key.strip().lower()
145        if (key != 'content-disposition'):
146            continue
147
148        # The Python stdlib recommends using the email library for this parsing,
149        # but I have not had a good experience with it.
150        for part in value.strip().split(';'):
151            part = part.strip()
152
153            parts = part.split('=')
154            if (len(parts) != 2):
155                continue
156
157            cd_key = parts[0].strip()
158            cd_value = parts[1].strip().strip('"')
159
160            values[cd_key] = cd_value
161
162    return values
163
164def parse_query_string(text: str,
165        replace_single_lists: bool = True,
166        keep_blank_values: bool = True,
167        **kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
168    """
169    Parse a query string (like urllib.parse.parse_qs()), and normalize the result.
170    If specified, lists with single values (as returned from urllib.parse.parse_qs()) will be replaced with the single value.
171    """
172
173    results = urllib.parse.parse_qs(text, keep_blank_values = True)
174    for (key, value) in results.items():
175        if (replace_single_lists and (len(value) == 1)):
176            results[key] = value[0]  # type: ignore[assignment]
177
178    return results
DEFAULT_START_PORT: int = 30000
DEFAULT_END_PORT: int = 40000
DEFAULT_PORT_SEARCH_WAIT_SEC: float = 0.01
def find_open_port( start_port: int = 30000, end_port: int = 40000, wait_time: float = 0.01) -> int:
26def find_open_port(
27        start_port: int = DEFAULT_START_PORT,
28        end_port: int = DEFAULT_END_PORT,
29        wait_time: float = DEFAULT_PORT_SEARCH_WAIT_SEC,
30        ) -> int:
31    """
32    Find an open port on this machine within the given range (inclusive).
33    If no open port is found, an error is raised.
34    """
35
36    for port in range(start_port, end_port + 1):
37        try:
38            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
39            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
40            sock.bind(('127.0.0.1', port))
41
42            # Explicitly close the port and wait a short amount of time for the port to clear.
43            # This should not be required because of the socket option above,
44            # but the cost is small.
45            sock.close()
46            time.sleep(DEFAULT_PORT_SEARCH_WAIT_SEC)
47
48            return port
49        except socket.error as ex:
50            sock.close()
51
52            if (ex.errno == errno.EADDRINUSE):
53                continue
54
55            # Unknown error.
56            raise ex
57
58    raise ValueError(f"Could not find open port in [{start_port}, {end_port}].")

Find an open port on this machine within the given range (inclusive). If no open port is found, an error is raised.

def parse_request_data( url: Optional[str], headers: Union[email.message.Message, Dict[str, Any]], body: Union[bytes, str, io.BufferedIOBase]) -> Tuple[Dict[str, Any], Dict[str, bytes]]:
60def parse_request_data(
61        url: typing.Union[str, None],
62        headers: typing.Union[email.message.Message, typing.Dict[str, typing.Any]],
63        body: typing.Union[bytes, str, io.BufferedIOBase],
64        ) -> typing.Tuple[typing.Dict[str, typing.Any], typing.Dict[str, bytes]]:
65    """ Parse data and files from an HTTP request URL and body. """
66
67    # Parse data from the request body.
68    request_data, request_files = parse_request_body_data(headers, body)
69
70    # Parse parameters from the URL.
71    if (url is not None):
72        url_parts = urllib.parse.urlparse(url)
73        request_data.update(parse_query_string(url_parts.query))
74
75    return request_data, request_files

Parse data and files from an HTTP request URL and body.

def parse_request_body_data( headers: Union[email.message.Message, Dict[str, Any]], body: Union[bytes, str, io.BufferedIOBase]) -> Tuple[Dict[str, Any], Dict[str, bytes]]:
 77def parse_request_body_data(
 78        headers: typing.Union[email.message.Message, typing.Dict[str, typing.Any]],
 79        body: typing.Union[bytes, str, io.BufferedIOBase],
 80        ) -> typing.Tuple[typing.Dict[str, typing.Any], typing.Dict[str, bytes]]:
 81    """ Parse data and files from an HTTP request body. """
 82
 83    data: typing.Dict[str, typing.Any] = {}
 84    files: typing.Dict[str, bytes] = {}
 85
 86    length = int(headers.get('content-length', 0))
 87    if (length == 0):
 88        return data, files
 89
 90    if (isinstance(body, io.BufferedIOBase)):
 91        raw_content = body.read(length)
 92    elif (isinstance(body, str)):
 93        raw_content = body.encode(edq.util.dirent.DEFAULT_ENCODING)
 94    else:
 95        raw_content = body
 96
 97    content_type = headers.get('content-type', '').lower()
 98
 99    if (content_type.startswith('text/plain')):
100        data[''] = raw_content.decode(edq.util.dirent.DEFAULT_ENCODING).strip()
101        return data, files
102
103    if (content_type in ['', 'application/x-www-form-urlencoded']):
104        data = parse_query_string(raw_content.decode(edq.util.dirent.DEFAULT_ENCODING).strip())
105        return data, files
106
107    if (content_type.startswith('multipart/form-data')):
108        decoder = requests_toolbelt.multipart.decoder.MultipartDecoder(
109            raw_content, content_type, encoding = edq.util.dirent.DEFAULT_ENCODING)
110
111        for multipart_section in decoder.parts:
112            values = parse_content_dispositions(multipart_section.headers)
113
114            name = values.get('name', None)
115            if (name is None):
116                raise ValueError("Could not find name for multipart section.")
117
118            # Look for a "filename" field to indicate a multipart section is a file.
119            # The file's desired name is still in "name", but an alternate name is in "filename".
120            if ('filename' in values):
121                filename = values.get('name', '')
122                if (filename == ''):
123                    raise ValueError("Unable to find filename for multipart section.")
124
125                files[filename] = multipart_section.content
126            else:
127                # Normal Parameter
128                data[name] = multipart_section.text
129
130        return data, files
131
132    raise ValueError(f"Unknown content type: '{content_type}'.")

Parse data and files from an HTTP request body.

def parse_content_dispositions(headers: Union[email.message.Message, Dict[str, Any]]) -> Dict[str, Any]:
134def parse_content_dispositions(headers: typing.Union[email.message.Message, typing.Dict[str, typing.Any]]) -> typing.Dict[str, typing.Any]:
135    """ Parse a request's content dispositions from headers. """
136
137    values = {}
138    for (key, value) in headers.items():
139        if (isinstance(key, bytes)):
140            key = key.decode(edq.util.dirent.DEFAULT_ENCODING)
141
142        if (isinstance(value, bytes)):
143            value = value.decode(edq.util.dirent.DEFAULT_ENCODING)
144
145        key = key.strip().lower()
146        if (key != 'content-disposition'):
147            continue
148
149        # The Python stdlib recommends using the email library for this parsing,
150        # but I have not had a good experience with it.
151        for part in value.strip().split(';'):
152            part = part.strip()
153
154            parts = part.split('=')
155            if (len(parts) != 2):
156                continue
157
158            cd_key = parts[0].strip()
159            cd_value = parts[1].strip().strip('"')
160
161            values[cd_key] = cd_value
162
163    return values

Parse a request's content dispositions from headers.

def parse_query_string( text: str, replace_single_lists: bool = True, keep_blank_values: bool = True, **kwargs: Any) -> Dict[str, Any]:
165def parse_query_string(text: str,
166        replace_single_lists: bool = True,
167        keep_blank_values: bool = True,
168        **kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
169    """
170    Parse a query string (like urllib.parse.parse_qs()), and normalize the result.
171    If specified, lists with single values (as returned from urllib.parse.parse_qs()) will be replaced with the single value.
172    """
173
174    results = urllib.parse.parse_qs(text, keep_blank_values = True)
175    for (key, value) in results.items():
176        if (replace_single_lists and (len(value) == 1)):
177            results[key] = value[0]  # type: ignore[assignment]
178
179    return results

Parse a query string (like urllib.parse.parse_qs()), and normalize the result. If specified, lists with single values (as returned from urllib.parse.parse_qs()) will be replaced with the single value.