Module thimble

Expand source code
from asyncio import get_event_loop, start_server
from os import stat
from re import match, search

class Thimble:
    """
    A tiny web framework in the spirit of Flask, scaled down to run on microcontrollers
    """
    def __init__(self, default_content_type='application/octet-stream', req_buffer_size=1024):
        self.routes = {}  # Dictionary to map method and URL path combinations to functions
        self.default_content_type = default_content_type
        self.req_buffer_size = req_buffer_size
        self.static_folder = '/static'
        self.directory_index = 'index.html'
        self.error_text = {
            400: "400: Bad Request",
            404: "404: Not Found",
            500: "500: Internal Server Error"
        }
        self.media_types = {
            'css': 'text/css',
            'html': 'text/html',
            'ico': 'image/vnd.microsoft.icon',
            'jpg': 'image/jpeg',
            'js': 'text/javascript',
            'json': 'application/json',
            'otf': 'font/otf',
            'png': 'image/png',
            'svg': 'image/svg+xml',
            'ttf': 'font/ttf',
            'txt': 'text/plain',
            'woff': 'font/woff',
            'woff2': 'font/woff2'
        }

    server_name = 'Thimble (MicroPython)'  # Used in 'Server' response header.

    @staticmethod
    def parse_query_string(query_string):
        """
        Split a URL's query string into individual key/value pairs
        (ex: 'pet=Panda&color=Red' becomes { "pet": "panda", "color": "red"}
        Args:
            query_string (string): the query string portion of a URL (without the leading ? delimiter)

        Returns:
            dictionary: key/value pairs
        """
        query = {}
        query_params = query_string.split('&')
        for param in query_params:
            if '=' not in param:  # A key with no value, like: 'red' instead of 'color=red'
                key = param
                query[key] = ''
            else:
                key, value = param.split('=')
                query[key] = value

        return query

    @staticmethod
    async def parse_http_request(req_buffer):
        """
        Given a raw HTTP request, return a dictionary with individual elements broken out

        Args:
            req_buffer (bytes): the unprocessed HTTP request sent from the client

        Raises:
            exception: when the request buffer is empty

        Returns:
            dictionary: key/value pairs including, but not limited to method, path, query, headers, body, etc.
                or None type if parsing fails
        """
        assert (req_buffer != b''), 'Empty request buffer.'

        req = {}
        req_buffer_string = req_buffer.decode('utf8')
        req_buffer_lines = req_buffer_string.split('\r\n')
        del req_buffer_string  # free up for garbage collection

        req['method'], target, req['http_version'] = req_buffer_lines[0].split(
            ' ', 2)  # Example: GET /route/path HTTP/1.1
        if '?' not in target:
            req['path'] = target
        else:  # target can have a query component, so /route/path could be something like /route/path?state=on&timeout=30
            req['path'], query_string = target.split('?', 1)
            req['query'] = Thimble.parse_query_string(query_string)

        req['headers'] = {}
        for i in range(1, len(req_buffer_lines) - 1):
            # Blank line signifies the end of headers.
            if req_buffer_lines[i] == '':
                break
            else:
                name, value = req_buffer_lines[i].split(':', 1)
                req['headers'][name.strip().lower()] = value.strip()

        # Last line is the body (or blank if no body.)
        req['body'] = req_buffer_lines[len(req_buffer_lines) - 1]

        return req

    @staticmethod
    async def http_status_line(status_code):
        """
        Given an HTTP status code (e.g. 200, 404, etc.), format the server response status line

        Args:
            status_code (int): the HTTP status code as defined by RFC 7231 Respone Status Codes (ex. 200)

        Returns:
            string: HTTP status line with protocol version, numeric status code, and corresponding status text
        """
        http_status_message = {
            200: "200 OK",
            302: "302 Found",
            400: "400 Bad Request",
            404: "404 Not Found",
            500: "500 Internal Server Error"
        }

        if status_code is None or status_code not in http_status_message:
            status_code = 500

        return f'HTTP/1.1 {http_status_message[status_code]}\r\n'

    @staticmethod
    async def http_headers(content_length=0, content_type='', content_encoding=''):
        """
        Generate appropriate HTTP response headers based on content properties

        Args:
            content_length (int): length of body, used for Content-Length header
            content_type (string): media-type of body, used for Content-Type header
            content_encoding(string): compression type, used for Content-Encoding header

        Returns:
            string: HTTP headers separated by \r\n
        """
        headers = 'Connection: close\r\n'
        if content_encoding != '':
            headers += f'Content-Encoding: {content_encoding}\r\n'
        headers += f'Content-Length: {content_length}\r\n'
        if content_type != '':
            headers += f'Content-Type: {content_type}\r\n'
        headers += f'Server: {Thimble.server_name}\r\n'
        headers += '\r\n'  # blank line signifies end of headers

        return headers

    # MicroPython does not have a built-in types module, so to avoid external dependencies this method lets
    # Thimble determine if route functions should be awaited or not by comparing their type to known async
    # and regular functions that are already part of the code.

    @staticmethod
    def is_async(func):
        """
        Determine if a function is async not by comparing its type to known async and regular functions.

        Args:
            func (object): a reference to the function being examined

        Returns:
            boolean: True if the function was defined as asynchronous, False if not, and None if unknown
        """
        if type(func) == type(Thimble.on_connect):  # noqa: E721
            return True  # It's an async function
        elif type(func) == type(Thimble.run):  # noqa: E721
            return False  # It's a regular function
        else:
            return None  # It's not a function

    async def send_error(self, error_number, writer):
        """
        Given a stream and an HTTP error number, send a friendly text error message.

        Args:
            error_number (integer): HTTP status code
            writer (object): the asyncio Stream object to which the file should be sent

        Returns:
            nothing
        """
        writer.write(await Thimble.http_status_line(error_number))
        error_text = f'{self.error_text[error_number]}\r\n'
        writer.write(await Thimble.http_headers(content_type='text/plain', content_length=len(error_text)))
        writer.write(error_text)
        await writer.drain()

    async def send_function_results(self, func, req, url_wildcard, writer):
        """
        Execute the given function with the HTTP reqest parameters as an argument and send the results as an HTTP reply

        Args:
            func (object): reference to the function to be executed or a tuple of function and URL wildcard
            req (dictionary): HTTP request parameters
            url_wildcard (various types): regex-matched portion of the url_path (or None for non-regex routes)
            writer (object): the asyncio Stream object to which the results should be sent

        Returns:
            nothing
        """
        try:
            if Thimble.is_async(func) is True:  # await the async function
                if url_wildcard is not None:
                    func_result = await func(req, url_wildcard)
                else:
                    func_result = await func(req)
            else:  # no awaiting required for non-async
                if url_wildcard is not None:
                    func_result = func(req, url_wildcard)
                else:
                    func_result = func(req)

        except Exception as ex:
            await self.send_error(500, writer)
            print(f'Function call failed: {ex}')
        else:
            if isinstance(func_result, tuple) and len(func_result) == 3:
                body, status_code, content_type = func_result
            elif isinstance(func_result, tuple) and len(func_result) == 2:
                body, status_code = func_result
                content_type = 'text/plain'
            else:
                body = func_result
                status_code = 200
                content_type = 'text/plain'

            if not isinstance(body, str):
                body = str(body)
            writer.write(await Thimble.http_status_line(status_code))
            writer.write(await Thimble.http_headers(content_length=len(body), content_type=content_type))
            await writer.drain()
            writer.write(body)
            await writer.drain()

    @staticmethod
    async def file_size(file_path):
        """
        Given a path to a file, return the file's size or None if there's an exception when checking.

        Args:
            file_path (string): a fully-qualified path to the location of the file

        Returns:
            file size in bytes or None if there was a problem obtaining the size (e.g. file does not exist)
        """

        try:
            size = stat(file_path)[6]
        except OSError:
            size = None

        return size

    async def file_type(self, file_path):
        """
        Return a standard media type / subtype based on file extension

        Args:
            file_path (string): file name or full path

        Returns:
            string: media type as registered with the Internet Assigned Numbers Authority (IANA)
        """
        file_ext = file_path.split('.')[-1]
        if file_ext not in self.media_types:
            return self.default_content_type
        else:
            return self.media_types[file_ext]

    @staticmethod
    def read_file_chunk(file):
        """
        Given a file handle, read the file in small chunks to avoid large buffer requirements.

        Args:
            file (object): the file handle returned by open()

        Returns:
            bytes: a chunk of the file until the file ends, then nothing
        """
        while True:
            chunk = file.read(512)  # small chunks to avoid out of memory errors
            if chunk:
                yield chunk
            else:  # empty chunk means end of the file
                return

    async def send_file_contents(self, file_path, req, writer):
        """
        Given a file path and an output stream, send HTTP status, headers, and file contents as body.
        If client accepts gzip encoding, and a file of the same name with a .gzip extension appended
        exists, the gzipped version will be sent. This is not so much for speeding up transfer as it
        for conserving limited flash filesystem space.

        Args:
            file_path (string): fully-qualified path to file
            writer (object): the asyncio Stream object to which the file should be sent

        Returns:
            nothing
        """
        # file_size is also used as an indicator of the file's existence
        file_gzip_size = await Thimble.file_size(file_path + '.gzip')
        file_size = await Thimble.file_size(file_path)
        file_type = await self.file_type(file_path)

        if file_gzip_size is not None and 'accept-encoding' in req['headers'] and 'gzip' in req['headers']['accept-encoding'].lower():
            writer.write(await Thimble.http_status_line(200))
            writer.write(await Thimble.http_headers(content_length=file_gzip_size, content_type=file_type, content_encoding='gzip'))
            with open(file_path + '.gzip', 'rb') as file:
                for chunk in Thimble.read_file_chunk(file):
                    writer.write(chunk)
                    await writer.drain()  # drain immediately after write to avoid memory allocation errors
        elif file_size is not None:  # a non-compressed file was found
            writer.write(await Thimble.http_status_line(200))
            writer.write(await Thimble.http_headers(content_length=file_size, content_type=file_type))
            with open(file_path, 'rb') as file:
                for chunk in Thimble.read_file_chunk(file):
                    writer.write(chunk)
                    await writer.drain()
        else:  # no file was found
            await self.send_error(404, writer)
            print(f'Error reading file: {file_path}')

    def route(self, url_path, methods=['GET']):
        """
        Given a URL path and list of zero or more HTTP methods, add the decorated function to the route table.

        Args:
            url_path (string): path portion of a URL (ex. '/path/to/thing') that will trigger a call to the function
            methods (list): a list of any HTTP methods (eg. ['GET', 'PUT']) that are used to trigger the call

        Returns:
            object: wrapper function
        """
        regex_macros = {
            '<digit>': '([0-9])',
            '<float>': '([-+]?[0-9]*\.?[0-9]+)',
            '<int>': '([0-9]+)',
            '<string>': '(.*)'
        }

        regex_match = search('(<.*>)', url_path)
        if regex_match:
            url_path = url_path.replace(regex_match.group(
                1), regex_macros[regex_match.group(1)])

        def add_route(func):
            for method in methods:
                # Methods are uppercase (see RFC 9110)
                self.routes[method.upper() + url_path] = func

        return add_route

    def resolve_route(self, route_pattern):
        """
        Given a route pattern (METHOD + url_path), look up the corresponding function.

        Args:
            route_pattern (string): An uppercase HTTP method concatenated with a URL path wich may contain regex (ex: GET/gpio/([0-9+])$)

        Returns:
            object: reference to function (for non-regex URLs) or tuple with function and regex capture (for regex URLs)
        """
        result = None
        if route_pattern in self.routes:  # pattern is a fixed string, like: 'GET/gpio/2'
            result = self.routes[route_pattern]
        else:  # pattern may contain regex, like 'GET/gpio/([0-9]+)'
            for key in self.routes.keys():
                regex_match = match(key, route_pattern)
                if regex_match:
                    func = self.routes[key]
                    wildcard_value = regex_match.group(1)
                    if self.debug:
                        print(f'Wildcard match: {wildcard_value}')
                    result = func, wildcard_value

        return result

    async def on_connect(self, reader, writer):
        """
        Connection handler for new client requests.

        Args:
            reader (stream): input received from the client
            writer (stream): output to be sent to the client

        Returns:
            nothing
        """
        client_ip = writer.get_extra_info('peername')[0]
        if self.debug:
            print(f'Connection from client: {client_ip}')

        try:
            req_buffer = await reader.read(self.req_buffer_size)
            req = await Thimble.parse_http_request(req_buffer)
            if self.debug:
                print(f'Request: {req}')
        except Exception as ex:
            await self.send_error(400, writer)
            print(f'Unable to parse request: {ex}')
        else:
            route_value = self.resolve_route(req['method'] + req['path'])
            if isinstance(route_value, tuple):  # a function and URL wildcard value were returned
                await self.send_function_results(route_value[0], req, route_value[1], writer)
            elif route_value is not None:  # just a function was returned
                await self.send_function_results(route_value, req, None, writer)
            else:  # nothing returned, try delivering static content instead
                file_path = self.static_folder + req['path']
                if file_path.endswith('/'):  # '/path/to/' becomes '/path/to/index.html'
                    file_path = file_path + self.directory_index
                await self.send_file_contents(file_path, req, writer)

        await writer.drain()
        writer.close()
        await writer.wait_closed()
        reader.close()
        await reader.wait_closed()
        if self.debug:
            print(f'Connection closed for {client_ip}')

    def run(self, host='0.0.0.0', port=80, loop=None, debug=False):
        """
        Start an asynchronous listener for HTTP requests.

        Args:
            host (string): the IP address of the interface on which to listen
            port (int): the TCP port on which to listen
            loop (object): the asyncio loop that the server should insert itself into
            debug (boolean): a flag to indicate verbose logging is desired

        Returns:
            object: the same loop object given as a parameter or a new one if no existing loop was passed
        """
        self.debug = debug
        print(f'Listening on {host}:{port}')

        if loop is None:
            loop = get_event_loop()
            server = start_server(self.on_connect, host, port, 5)
            loop.create_task(server)
            loop.run_forever()
        else:
            server = start_server(self.on_connect, host, port, 5)
            loop.create_task(server)

        return loop

Classes

class Thimble (default_content_type='application/octet-stream', req_buffer_size=1024)

A tiny web framework in the spirit of Flask, scaled down to run on microcontrollers

Expand source code
class Thimble:
    """
    A tiny web framework in the spirit of Flask, scaled down to run on microcontrollers
    """
    def __init__(self, default_content_type='application/octet-stream', req_buffer_size=1024):
        self.routes = {}  # Dictionary to map method and URL path combinations to functions
        self.default_content_type = default_content_type
        self.req_buffer_size = req_buffer_size
        self.static_folder = '/static'
        self.directory_index = 'index.html'
        self.error_text = {
            400: "400: Bad Request",
            404: "404: Not Found",
            500: "500: Internal Server Error"
        }
        self.media_types = {
            'css': 'text/css',
            'html': 'text/html',
            'ico': 'image/vnd.microsoft.icon',
            'jpg': 'image/jpeg',
            'js': 'text/javascript',
            'json': 'application/json',
            'otf': 'font/otf',
            'png': 'image/png',
            'svg': 'image/svg+xml',
            'ttf': 'font/ttf',
            'txt': 'text/plain',
            'woff': 'font/woff',
            'woff2': 'font/woff2'
        }

    server_name = 'Thimble (MicroPython)'  # Used in 'Server' response header.

    @staticmethod
    def parse_query_string(query_string):
        """
        Split a URL's query string into individual key/value pairs
        (ex: 'pet=Panda&color=Red' becomes { "pet": "panda", "color": "red"}
        Args:
            query_string (string): the query string portion of a URL (without the leading ? delimiter)

        Returns:
            dictionary: key/value pairs
        """
        query = {}
        query_params = query_string.split('&')
        for param in query_params:
            if '=' not in param:  # A key with no value, like: 'red' instead of 'color=red'
                key = param
                query[key] = ''
            else:
                key, value = param.split('=')
                query[key] = value

        return query

    @staticmethod
    async def parse_http_request(req_buffer):
        """
        Given a raw HTTP request, return a dictionary with individual elements broken out

        Args:
            req_buffer (bytes): the unprocessed HTTP request sent from the client

        Raises:
            exception: when the request buffer is empty

        Returns:
            dictionary: key/value pairs including, but not limited to method, path, query, headers, body, etc.
                or None type if parsing fails
        """
        assert (req_buffer != b''), 'Empty request buffer.'

        req = {}
        req_buffer_string = req_buffer.decode('utf8')
        req_buffer_lines = req_buffer_string.split('\r\n')
        del req_buffer_string  # free up for garbage collection

        req['method'], target, req['http_version'] = req_buffer_lines[0].split(
            ' ', 2)  # Example: GET /route/path HTTP/1.1
        if '?' not in target:
            req['path'] = target
        else:  # target can have a query component, so /route/path could be something like /route/path?state=on&timeout=30
            req['path'], query_string = target.split('?', 1)
            req['query'] = Thimble.parse_query_string(query_string)

        req['headers'] = {}
        for i in range(1, len(req_buffer_lines) - 1):
            # Blank line signifies the end of headers.
            if req_buffer_lines[i] == '':
                break
            else:
                name, value = req_buffer_lines[i].split(':', 1)
                req['headers'][name.strip().lower()] = value.strip()

        # Last line is the body (or blank if no body.)
        req['body'] = req_buffer_lines[len(req_buffer_lines) - 1]

        return req

    @staticmethod
    async def http_status_line(status_code):
        """
        Given an HTTP status code (e.g. 200, 404, etc.), format the server response status line

        Args:
            status_code (int): the HTTP status code as defined by RFC 7231 Respone Status Codes (ex. 200)

        Returns:
            string: HTTP status line with protocol version, numeric status code, and corresponding status text
        """
        http_status_message = {
            200: "200 OK",
            302: "302 Found",
            400: "400 Bad Request",
            404: "404 Not Found",
            500: "500 Internal Server Error"
        }

        if status_code is None or status_code not in http_status_message:
            status_code = 500

        return f'HTTP/1.1 {http_status_message[status_code]}\r\n'

    @staticmethod
    async def http_headers(content_length=0, content_type='', content_encoding=''):
        """
        Generate appropriate HTTP response headers based on content properties

        Args:
            content_length (int): length of body, used for Content-Length header
            content_type (string): media-type of body, used for Content-Type header
            content_encoding(string): compression type, used for Content-Encoding header

        Returns:
            string: HTTP headers separated by \r\n
        """
        headers = 'Connection: close\r\n'
        if content_encoding != '':
            headers += f'Content-Encoding: {content_encoding}\r\n'
        headers += f'Content-Length: {content_length}\r\n'
        if content_type != '':
            headers += f'Content-Type: {content_type}\r\n'
        headers += f'Server: {Thimble.server_name}\r\n'
        headers += '\r\n'  # blank line signifies end of headers

        return headers

    # MicroPython does not have a built-in types module, so to avoid external dependencies this method lets
    # Thimble determine if route functions should be awaited or not by comparing their type to known async
    # and regular functions that are already part of the code.

    @staticmethod
    def is_async(func):
        """
        Determine if a function is async not by comparing its type to known async and regular functions.

        Args:
            func (object): a reference to the function being examined

        Returns:
            boolean: True if the function was defined as asynchronous, False if not, and None if unknown
        """
        if type(func) == type(Thimble.on_connect):  # noqa: E721
            return True  # It's an async function
        elif type(func) == type(Thimble.run):  # noqa: E721
            return False  # It's a regular function
        else:
            return None  # It's not a function

    async def send_error(self, error_number, writer):
        """
        Given a stream and an HTTP error number, send a friendly text error message.

        Args:
            error_number (integer): HTTP status code
            writer (object): the asyncio Stream object to which the file should be sent

        Returns:
            nothing
        """
        writer.write(await Thimble.http_status_line(error_number))
        error_text = f'{self.error_text[error_number]}\r\n'
        writer.write(await Thimble.http_headers(content_type='text/plain', content_length=len(error_text)))
        writer.write(error_text)
        await writer.drain()

    async def send_function_results(self, func, req, url_wildcard, writer):
        """
        Execute the given function with the HTTP reqest parameters as an argument and send the results as an HTTP reply

        Args:
            func (object): reference to the function to be executed or a tuple of function and URL wildcard
            req (dictionary): HTTP request parameters
            url_wildcard (various types): regex-matched portion of the url_path (or None for non-regex routes)
            writer (object): the asyncio Stream object to which the results should be sent

        Returns:
            nothing
        """
        try:
            if Thimble.is_async(func) is True:  # await the async function
                if url_wildcard is not None:
                    func_result = await func(req, url_wildcard)
                else:
                    func_result = await func(req)
            else:  # no awaiting required for non-async
                if url_wildcard is not None:
                    func_result = func(req, url_wildcard)
                else:
                    func_result = func(req)

        except Exception as ex:
            await self.send_error(500, writer)
            print(f'Function call failed: {ex}')
        else:
            if isinstance(func_result, tuple) and len(func_result) == 3:
                body, status_code, content_type = func_result
            elif isinstance(func_result, tuple) and len(func_result) == 2:
                body, status_code = func_result
                content_type = 'text/plain'
            else:
                body = func_result
                status_code = 200
                content_type = 'text/plain'

            if not isinstance(body, str):
                body = str(body)
            writer.write(await Thimble.http_status_line(status_code))
            writer.write(await Thimble.http_headers(content_length=len(body), content_type=content_type))
            await writer.drain()
            writer.write(body)
            await writer.drain()

    @staticmethod
    async def file_size(file_path):
        """
        Given a path to a file, return the file's size or None if there's an exception when checking.

        Args:
            file_path (string): a fully-qualified path to the location of the file

        Returns:
            file size in bytes or None if there was a problem obtaining the size (e.g. file does not exist)
        """

        try:
            size = stat(file_path)[6]
        except OSError:
            size = None

        return size

    async def file_type(self, file_path):
        """
        Return a standard media type / subtype based on file extension

        Args:
            file_path (string): file name or full path

        Returns:
            string: media type as registered with the Internet Assigned Numbers Authority (IANA)
        """
        file_ext = file_path.split('.')[-1]
        if file_ext not in self.media_types:
            return self.default_content_type
        else:
            return self.media_types[file_ext]

    @staticmethod
    def read_file_chunk(file):
        """
        Given a file handle, read the file in small chunks to avoid large buffer requirements.

        Args:
            file (object): the file handle returned by open()

        Returns:
            bytes: a chunk of the file until the file ends, then nothing
        """
        while True:
            chunk = file.read(512)  # small chunks to avoid out of memory errors
            if chunk:
                yield chunk
            else:  # empty chunk means end of the file
                return

    async def send_file_contents(self, file_path, req, writer):
        """
        Given a file path and an output stream, send HTTP status, headers, and file contents as body.
        If client accepts gzip encoding, and a file of the same name with a .gzip extension appended
        exists, the gzipped version will be sent. This is not so much for speeding up transfer as it
        for conserving limited flash filesystem space.

        Args:
            file_path (string): fully-qualified path to file
            writer (object): the asyncio Stream object to which the file should be sent

        Returns:
            nothing
        """
        # file_size is also used as an indicator of the file's existence
        file_gzip_size = await Thimble.file_size(file_path + '.gzip')
        file_size = await Thimble.file_size(file_path)
        file_type = await self.file_type(file_path)

        if file_gzip_size is not None and 'accept-encoding' in req['headers'] and 'gzip' in req['headers']['accept-encoding'].lower():
            writer.write(await Thimble.http_status_line(200))
            writer.write(await Thimble.http_headers(content_length=file_gzip_size, content_type=file_type, content_encoding='gzip'))
            with open(file_path + '.gzip', 'rb') as file:
                for chunk in Thimble.read_file_chunk(file):
                    writer.write(chunk)
                    await writer.drain()  # drain immediately after write to avoid memory allocation errors
        elif file_size is not None:  # a non-compressed file was found
            writer.write(await Thimble.http_status_line(200))
            writer.write(await Thimble.http_headers(content_length=file_size, content_type=file_type))
            with open(file_path, 'rb') as file:
                for chunk in Thimble.read_file_chunk(file):
                    writer.write(chunk)
                    await writer.drain()
        else:  # no file was found
            await self.send_error(404, writer)
            print(f'Error reading file: {file_path}')

    def route(self, url_path, methods=['GET']):
        """
        Given a URL path and list of zero or more HTTP methods, add the decorated function to the route table.

        Args:
            url_path (string): path portion of a URL (ex. '/path/to/thing') that will trigger a call to the function
            methods (list): a list of any HTTP methods (eg. ['GET', 'PUT']) that are used to trigger the call

        Returns:
            object: wrapper function
        """
        regex_macros = {
            '<digit>': '([0-9])',
            '<float>': '([-+]?[0-9]*\.?[0-9]+)',
            '<int>': '([0-9]+)',
            '<string>': '(.*)'
        }

        regex_match = search('(<.*>)', url_path)
        if regex_match:
            url_path = url_path.replace(regex_match.group(
                1), regex_macros[regex_match.group(1)])

        def add_route(func):
            for method in methods:
                # Methods are uppercase (see RFC 9110)
                self.routes[method.upper() + url_path] = func

        return add_route

    def resolve_route(self, route_pattern):
        """
        Given a route pattern (METHOD + url_path), look up the corresponding function.

        Args:
            route_pattern (string): An uppercase HTTP method concatenated with a URL path wich may contain regex (ex: GET/gpio/([0-9+])$)

        Returns:
            object: reference to function (for non-regex URLs) or tuple with function and regex capture (for regex URLs)
        """
        result = None
        if route_pattern in self.routes:  # pattern is a fixed string, like: 'GET/gpio/2'
            result = self.routes[route_pattern]
        else:  # pattern may contain regex, like 'GET/gpio/([0-9]+)'
            for key in self.routes.keys():
                regex_match = match(key, route_pattern)
                if regex_match:
                    func = self.routes[key]
                    wildcard_value = regex_match.group(1)
                    if self.debug:
                        print(f'Wildcard match: {wildcard_value}')
                    result = func, wildcard_value

        return result

    async def on_connect(self, reader, writer):
        """
        Connection handler for new client requests.

        Args:
            reader (stream): input received from the client
            writer (stream): output to be sent to the client

        Returns:
            nothing
        """
        client_ip = writer.get_extra_info('peername')[0]
        if self.debug:
            print(f'Connection from client: {client_ip}')

        try:
            req_buffer = await reader.read(self.req_buffer_size)
            req = await Thimble.parse_http_request(req_buffer)
            if self.debug:
                print(f'Request: {req}')
        except Exception as ex:
            await self.send_error(400, writer)
            print(f'Unable to parse request: {ex}')
        else:
            route_value = self.resolve_route(req['method'] + req['path'])
            if isinstance(route_value, tuple):  # a function and URL wildcard value were returned
                await self.send_function_results(route_value[0], req, route_value[1], writer)
            elif route_value is not None:  # just a function was returned
                await self.send_function_results(route_value, req, None, writer)
            else:  # nothing returned, try delivering static content instead
                file_path = self.static_folder + req['path']
                if file_path.endswith('/'):  # '/path/to/' becomes '/path/to/index.html'
                    file_path = file_path + self.directory_index
                await self.send_file_contents(file_path, req, writer)

        await writer.drain()
        writer.close()
        await writer.wait_closed()
        reader.close()
        await reader.wait_closed()
        if self.debug:
            print(f'Connection closed for {client_ip}')

    def run(self, host='0.0.0.0', port=80, loop=None, debug=False):
        """
        Start an asynchronous listener for HTTP requests.

        Args:
            host (string): the IP address of the interface on which to listen
            port (int): the TCP port on which to listen
            loop (object): the asyncio loop that the server should insert itself into
            debug (boolean): a flag to indicate verbose logging is desired

        Returns:
            object: the same loop object given as a parameter or a new one if no existing loop was passed
        """
        self.debug = debug
        print(f'Listening on {host}:{port}')

        if loop is None:
            loop = get_event_loop()
            server = start_server(self.on_connect, host, port, 5)
            loop.create_task(server)
            loop.run_forever()
        else:
            server = start_server(self.on_connect, host, port, 5)
            loop.create_task(server)

        return loop

Class variables

var server_name

Static methods

async def file_size(file_path)

Given a path to a file, return the file's size or None if there's an exception when checking.

Args

file_path : string
a fully-qualified path to the location of the file

Returns

file size in bytes or None if there was a problem obtaining the size (e.g. file does not exist)

Expand source code
@staticmethod
async def file_size(file_path):
    """
    Given a path to a file, return the file's size or None if there's an exception when checking.

    Args:
        file_path (string): a fully-qualified path to the location of the file

    Returns:
        file size in bytes or None if there was a problem obtaining the size (e.g. file does not exist)
    """

    try:
        size = stat(file_path)[6]
    except OSError:
        size = None

    return size
async def http_headers(content_length=0, content_type='', content_encoding='')

Generate appropriate HTTP response headers based on content properties

Args

content_length : int
length of body, used for Content-Length header
content_type : string
media-type of body, used for Content-Type header

content_encoding(string): compression type, used for Content-Encoding header

Returns

string
HTTP headers separated by
Expand source code
@staticmethod
async def http_headers(content_length=0, content_type='', content_encoding=''):
    """
    Generate appropriate HTTP response headers based on content properties

    Args:
        content_length (int): length of body, used for Content-Length header
        content_type (string): media-type of body, used for Content-Type header
        content_encoding(string): compression type, used for Content-Encoding header

    Returns:
        string: HTTP headers separated by \r\n
    """
    headers = 'Connection: close\r\n'
    if content_encoding != '':
        headers += f'Content-Encoding: {content_encoding}\r\n'
    headers += f'Content-Length: {content_length}\r\n'
    if content_type != '':
        headers += f'Content-Type: {content_type}\r\n'
    headers += f'Server: {Thimble.server_name}\r\n'
    headers += '\r\n'  # blank line signifies end of headers

    return headers
async def http_status_line(status_code)

Given an HTTP status code (e.g. 200, 404, etc.), format the server response status line

Args

status_code : int
the HTTP status code as defined by RFC 7231 Respone Status Codes (ex. 200)

Returns

string
HTTP status line with protocol version, numeric status code, and corresponding status text
Expand source code
@staticmethod
async def http_status_line(status_code):
    """
    Given an HTTP status code (e.g. 200, 404, etc.), format the server response status line

    Args:
        status_code (int): the HTTP status code as defined by RFC 7231 Respone Status Codes (ex. 200)

    Returns:
        string: HTTP status line with protocol version, numeric status code, and corresponding status text
    """
    http_status_message = {
        200: "200 OK",
        302: "302 Found",
        400: "400 Bad Request",
        404: "404 Not Found",
        500: "500 Internal Server Error"
    }

    if status_code is None or status_code not in http_status_message:
        status_code = 500

    return f'HTTP/1.1 {http_status_message[status_code]}\r\n'
def is_async(func)

Determine if a function is async not by comparing its type to known async and regular functions.

Args

func : object
a reference to the function being examined

Returns

boolean
True if the function was defined as asynchronous, False if not, and None if unknown
Expand source code
@staticmethod
def is_async(func):
    """
    Determine if a function is async not by comparing its type to known async and regular functions.

    Args:
        func (object): a reference to the function being examined

    Returns:
        boolean: True if the function was defined as asynchronous, False if not, and None if unknown
    """
    if type(func) == type(Thimble.on_connect):  # noqa: E721
        return True  # It's an async function
    elif type(func) == type(Thimble.run):  # noqa: E721
        return False  # It's a regular function
    else:
        return None  # It's not a function
async def parse_http_request(req_buffer)

Given a raw HTTP request, return a dictionary with individual elements broken out

Args

req_buffer : bytes
the unprocessed HTTP request sent from the client

Raises

exception
when the request buffer is empty

Returns

dictionary
key/value pairs including, but not limited to method, path, query, headers, body, etc. or None type if parsing fails
Expand source code
@staticmethod
async def parse_http_request(req_buffer):
    """
    Given a raw HTTP request, return a dictionary with individual elements broken out

    Args:
        req_buffer (bytes): the unprocessed HTTP request sent from the client

    Raises:
        exception: when the request buffer is empty

    Returns:
        dictionary: key/value pairs including, but not limited to method, path, query, headers, body, etc.
            or None type if parsing fails
    """
    assert (req_buffer != b''), 'Empty request buffer.'

    req = {}
    req_buffer_string = req_buffer.decode('utf8')
    req_buffer_lines = req_buffer_string.split('\r\n')
    del req_buffer_string  # free up for garbage collection

    req['method'], target, req['http_version'] = req_buffer_lines[0].split(
        ' ', 2)  # Example: GET /route/path HTTP/1.1
    if '?' not in target:
        req['path'] = target
    else:  # target can have a query component, so /route/path could be something like /route/path?state=on&timeout=30
        req['path'], query_string = target.split('?', 1)
        req['query'] = Thimble.parse_query_string(query_string)

    req['headers'] = {}
    for i in range(1, len(req_buffer_lines) - 1):
        # Blank line signifies the end of headers.
        if req_buffer_lines[i] == '':
            break
        else:
            name, value = req_buffer_lines[i].split(':', 1)
            req['headers'][name.strip().lower()] = value.strip()

    # Last line is the body (or blank if no body.)
    req['body'] = req_buffer_lines[len(req_buffer_lines) - 1]

    return req
def parse_query_string(query_string)

Split a URL's query string into individual key/value pairs (ex: 'pet=Panda&color=Red' becomes { "pet": "panda", "color": "red"}

Args

query_string : string
the query string portion of a URL (without the leading ? delimiter)

Returns

dictionary
key/value pairs
Expand source code
@staticmethod
def parse_query_string(query_string):
    """
    Split a URL's query string into individual key/value pairs
    (ex: 'pet=Panda&color=Red' becomes { "pet": "panda", "color": "red"}
    Args:
        query_string (string): the query string portion of a URL (without the leading ? delimiter)

    Returns:
        dictionary: key/value pairs
    """
    query = {}
    query_params = query_string.split('&')
    for param in query_params:
        if '=' not in param:  # A key with no value, like: 'red' instead of 'color=red'
            key = param
            query[key] = ''
        else:
            key, value = param.split('=')
            query[key] = value

    return query
def read_file_chunk(file)

Given a file handle, read the file in small chunks to avoid large buffer requirements.

Args

file : object
the file handle returned by open()

Returns

bytes
a chunk of the file until the file ends, then nothing
Expand source code
@staticmethod
def read_file_chunk(file):
    """
    Given a file handle, read the file in small chunks to avoid large buffer requirements.

    Args:
        file (object): the file handle returned by open()

    Returns:
        bytes: a chunk of the file until the file ends, then nothing
    """
    while True:
        chunk = file.read(512)  # small chunks to avoid out of memory errors
        if chunk:
            yield chunk
        else:  # empty chunk means end of the file
            return

Methods

async def file_type(self, file_path)

Return a standard media type / subtype based on file extension

Args

file_path : string
file name or full path

Returns

string
media type as registered with the Internet Assigned Numbers Authority (IANA)
Expand source code
async def file_type(self, file_path):
    """
    Return a standard media type / subtype based on file extension

    Args:
        file_path (string): file name or full path

    Returns:
        string: media type as registered with the Internet Assigned Numbers Authority (IANA)
    """
    file_ext = file_path.split('.')[-1]
    if file_ext not in self.media_types:
        return self.default_content_type
    else:
        return self.media_types[file_ext]
async def on_connect(self, reader, writer)

Connection handler for new client requests.

Args

reader : stream
input received from the client
writer : stream
output to be sent to the client

Returns

nothing

Expand source code
async def on_connect(self, reader, writer):
    """
    Connection handler for new client requests.

    Args:
        reader (stream): input received from the client
        writer (stream): output to be sent to the client

    Returns:
        nothing
    """
    client_ip = writer.get_extra_info('peername')[0]
    if self.debug:
        print(f'Connection from client: {client_ip}')

    try:
        req_buffer = await reader.read(self.req_buffer_size)
        req = await Thimble.parse_http_request(req_buffer)
        if self.debug:
            print(f'Request: {req}')
    except Exception as ex:
        await self.send_error(400, writer)
        print(f'Unable to parse request: {ex}')
    else:
        route_value = self.resolve_route(req['method'] + req['path'])
        if isinstance(route_value, tuple):  # a function and URL wildcard value were returned
            await self.send_function_results(route_value[0], req, route_value[1], writer)
        elif route_value is not None:  # just a function was returned
            await self.send_function_results(route_value, req, None, writer)
        else:  # nothing returned, try delivering static content instead
            file_path = self.static_folder + req['path']
            if file_path.endswith('/'):  # '/path/to/' becomes '/path/to/index.html'
                file_path = file_path + self.directory_index
            await self.send_file_contents(file_path, req, writer)

    await writer.drain()
    writer.close()
    await writer.wait_closed()
    reader.close()
    await reader.wait_closed()
    if self.debug:
        print(f'Connection closed for {client_ip}')
def resolve_route(self, route_pattern)

Given a route pattern (METHOD + url_path), look up the corresponding function.

Args

route_pattern : string
An uppercase HTTP method concatenated with a URL path wich may contain regex (ex: GET/gpio/([0-9+])$)

Returns

object
reference to function (for non-regex URLs) or tuple with function and regex capture (for regex URLs)
Expand source code
def resolve_route(self, route_pattern):
    """
    Given a route pattern (METHOD + url_path), look up the corresponding function.

    Args:
        route_pattern (string): An uppercase HTTP method concatenated with a URL path wich may contain regex (ex: GET/gpio/([0-9+])$)

    Returns:
        object: reference to function (for non-regex URLs) or tuple with function and regex capture (for regex URLs)
    """
    result = None
    if route_pattern in self.routes:  # pattern is a fixed string, like: 'GET/gpio/2'
        result = self.routes[route_pattern]
    else:  # pattern may contain regex, like 'GET/gpio/([0-9]+)'
        for key in self.routes.keys():
            regex_match = match(key, route_pattern)
            if regex_match:
                func = self.routes[key]
                wildcard_value = regex_match.group(1)
                if self.debug:
                    print(f'Wildcard match: {wildcard_value}')
                result = func, wildcard_value

    return result
def route(self, url_path, methods=['GET'])

Given a URL path and list of zero or more HTTP methods, add the decorated function to the route table.

Args

url_path : string
path portion of a URL (ex. '/path/to/thing') that will trigger a call to the function
methods : list
a list of any HTTP methods (eg. ['GET', 'PUT']) that are used to trigger the call

Returns

object
wrapper function
Expand source code
def route(self, url_path, methods=['GET']):
    """
    Given a URL path and list of zero or more HTTP methods, add the decorated function to the route table.

    Args:
        url_path (string): path portion of a URL (ex. '/path/to/thing') that will trigger a call to the function
        methods (list): a list of any HTTP methods (eg. ['GET', 'PUT']) that are used to trigger the call

    Returns:
        object: wrapper function
    """
    regex_macros = {
        '<digit>': '([0-9])',
        '<float>': '([-+]?[0-9]*\.?[0-9]+)',
        '<int>': '([0-9]+)',
        '<string>': '(.*)'
    }

    regex_match = search('(<.*>)', url_path)
    if regex_match:
        url_path = url_path.replace(regex_match.group(
            1), regex_macros[regex_match.group(1)])

    def add_route(func):
        for method in methods:
            # Methods are uppercase (see RFC 9110)
            self.routes[method.upper() + url_path] = func

    return add_route
def run(self, host='0.0.0.0', port=80, loop=None, debug=False)

Start an asynchronous listener for HTTP requests.

Args

host : string
the IP address of the interface on which to listen
port : int
the TCP port on which to listen
loop : object
the asyncio loop that the server should insert itself into
debug : boolean
a flag to indicate verbose logging is desired

Returns

object
the same loop object given as a parameter or a new one if no existing loop was passed
Expand source code
def run(self, host='0.0.0.0', port=80, loop=None, debug=False):
    """
    Start an asynchronous listener for HTTP requests.

    Args:
        host (string): the IP address of the interface on which to listen
        port (int): the TCP port on which to listen
        loop (object): the asyncio loop that the server should insert itself into
        debug (boolean): a flag to indicate verbose logging is desired

    Returns:
        object: the same loop object given as a parameter or a new one if no existing loop was passed
    """
    self.debug = debug
    print(f'Listening on {host}:{port}')

    if loop is None:
        loop = get_event_loop()
        server = start_server(self.on_connect, host, port, 5)
        loop.create_task(server)
        loop.run_forever()
    else:
        server = start_server(self.on_connect, host, port, 5)
        loop.create_task(server)

    return loop
async def send_error(self, error_number, writer)

Given a stream and an HTTP error number, send a friendly text error message.

Args

error_number : integer
HTTP status code
writer : object
the asyncio Stream object to which the file should be sent

Returns

nothing

Expand source code
async def send_error(self, error_number, writer):
    """
    Given a stream and an HTTP error number, send a friendly text error message.

    Args:
        error_number (integer): HTTP status code
        writer (object): the asyncio Stream object to which the file should be sent

    Returns:
        nothing
    """
    writer.write(await Thimble.http_status_line(error_number))
    error_text = f'{self.error_text[error_number]}\r\n'
    writer.write(await Thimble.http_headers(content_type='text/plain', content_length=len(error_text)))
    writer.write(error_text)
    await writer.drain()
async def send_file_contents(self, file_path, req, writer)

Given a file path and an output stream, send HTTP status, headers, and file contents as body. If client accepts gzip encoding, and a file of the same name with a .gzip extension appended exists, the gzipped version will be sent. This is not so much for speeding up transfer as it for conserving limited flash filesystem space.

Args

file_path : string
fully-qualified path to file
writer : object
the asyncio Stream object to which the file should be sent

Returns

nothing

Expand source code
async def send_file_contents(self, file_path, req, writer):
    """
    Given a file path and an output stream, send HTTP status, headers, and file contents as body.
    If client accepts gzip encoding, and a file of the same name with a .gzip extension appended
    exists, the gzipped version will be sent. This is not so much for speeding up transfer as it
    for conserving limited flash filesystem space.

    Args:
        file_path (string): fully-qualified path to file
        writer (object): the asyncio Stream object to which the file should be sent

    Returns:
        nothing
    """
    # file_size is also used as an indicator of the file's existence
    file_gzip_size = await Thimble.file_size(file_path + '.gzip')
    file_size = await Thimble.file_size(file_path)
    file_type = await self.file_type(file_path)

    if file_gzip_size is not None and 'accept-encoding' in req['headers'] and 'gzip' in req['headers']['accept-encoding'].lower():
        writer.write(await Thimble.http_status_line(200))
        writer.write(await Thimble.http_headers(content_length=file_gzip_size, content_type=file_type, content_encoding='gzip'))
        with open(file_path + '.gzip', 'rb') as file:
            for chunk in Thimble.read_file_chunk(file):
                writer.write(chunk)
                await writer.drain()  # drain immediately after write to avoid memory allocation errors
    elif file_size is not None:  # a non-compressed file was found
        writer.write(await Thimble.http_status_line(200))
        writer.write(await Thimble.http_headers(content_length=file_size, content_type=file_type))
        with open(file_path, 'rb') as file:
            for chunk in Thimble.read_file_chunk(file):
                writer.write(chunk)
                await writer.drain()
    else:  # no file was found
        await self.send_error(404, writer)
        print(f'Error reading file: {file_path}')
async def send_function_results(self, func, req, url_wildcard, writer)

Execute the given function with the HTTP reqest parameters as an argument and send the results as an HTTP reply

Args

func : object
reference to the function to be executed or a tuple of function and URL wildcard
req : dictionary
HTTP request parameters
url_wildcard : various types
regex-matched portion of the url_path (or None for non-regex routes)
writer : object
the asyncio Stream object to which the results should be sent

Returns

nothing

Expand source code
async def send_function_results(self, func, req, url_wildcard, writer):
    """
    Execute the given function with the HTTP reqest parameters as an argument and send the results as an HTTP reply

    Args:
        func (object): reference to the function to be executed or a tuple of function and URL wildcard
        req (dictionary): HTTP request parameters
        url_wildcard (various types): regex-matched portion of the url_path (or None for non-regex routes)
        writer (object): the asyncio Stream object to which the results should be sent

    Returns:
        nothing
    """
    try:
        if Thimble.is_async(func) is True:  # await the async function
            if url_wildcard is not None:
                func_result = await func(req, url_wildcard)
            else:
                func_result = await func(req)
        else:  # no awaiting required for non-async
            if url_wildcard is not None:
                func_result = func(req, url_wildcard)
            else:
                func_result = func(req)

    except Exception as ex:
        await self.send_error(500, writer)
        print(f'Function call failed: {ex}')
    else:
        if isinstance(func_result, tuple) and len(func_result) == 3:
            body, status_code, content_type = func_result
        elif isinstance(func_result, tuple) and len(func_result) == 2:
            body, status_code = func_result
            content_type = 'text/plain'
        else:
            body = func_result
            status_code = 200
            content_type = 'text/plain'

        if not isinstance(body, str):
            body = str(body)
        writer.write(await Thimble.http_status_line(status_code))
        writer.write(await Thimble.http_headers(content_length=len(body), content_type=content_type))
        await writer.drain()
        writer.write(body)
        await writer.drain()