From d92568b6fa623fb12e521baad8dddbc630b3b418 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Fri, 17 Feb 2023 09:04:30 +0100 Subject: [PATCH 1/8] create pre-releases with release and test-release workflow --- .github/workflows/release.yml | 17 +++++++++++++++-- .github/workflows/test-release.yaml | 17 +++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e7d0bd..0c4e7d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,14 +9,14 @@ on: - develop permissions: - contents: read + contents: write jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v3 with: @@ -41,3 +41,16 @@ jobs: skip_existing: true verbose: true print_hash: true + - name: 'Create changelog based release' + uses: brainelectronics/changelog-based-release@v1 + with: + # note you'll typically need to create a personal access token + # with permissions to create releases in the other repo + # or you set the "contents" permissions to "write" as in this example + changelog-path: changelog.md + tag-name-prefix: '' + tag-name-extension: '' + release-name-prefix: '' + release-name-extension: '' + draft-release: true + prerelease: false diff --git a/.github/workflows/test-release.yaml b/.github/workflows/test-release.yaml index 2c8fd2f..f3a1bdb 100644 --- a/.github/workflows/test-release.yaml +++ b/.github/workflows/test-release.yaml @@ -6,14 +6,14 @@ name: Upload Python Package to test.pypi.org on: [pull_request] permissions: - contents: read + contents: write jobs: test-deploy: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v3 with: @@ -51,3 +51,16 @@ jobs: skip_existing: true verbose: true print_hash: true + - name: 'Create changelog based prerelease' + uses: brainelectronics/changelog-based-release@v1 + with: + # note you'll typically need to create a personal access token + # with permissions to create releases in the other repo + # or you set the "contents" permissions to "write" as in this example + changelog-path: changelog.md + tag-name-prefix: '' + tag-name-extension: '-rc${{ github.run_number }}.dev${{ github.event.number }}' + release-name-prefix: '' + release-name-extension: '-rc${{ github.run_number }}.dev${{ github.event.number }}' + draft-release: true + prerelease: true From c7f8e090fd63b346d62fda17432d4d118edc2c73 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Fri, 17 Feb 2023 09:07:51 +0100 Subject: [PATCH 2/8] add microdot folder --- microdot/LICENSE | 21 + microdot/__init__.py | 5 + microdot/microdot.py | 1175 ++++++++++++++++++++++++++++++++ microdot/microdot_utemplate.py | 34 + 4 files changed, 1235 insertions(+) create mode 100644 microdot/LICENSE create mode 100644 microdot/__init__.py create mode 100644 microdot/microdot.py create mode 100644 microdot/microdot_utemplate.py diff --git a/microdot/LICENSE b/microdot/LICENSE new file mode 100644 index 0000000..ba45cea --- /dev/null +++ b/microdot/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Miguel Grinberg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/microdot/__init__.py b/microdot/__init__.py new file mode 100644 index 0000000..eb9372f --- /dev/null +++ b/microdot/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +from .microdot import * +from .microdot_utemplate import * diff --git a/microdot/microdot.py b/microdot/microdot.py new file mode 100644 index 0000000..5964823 --- /dev/null +++ b/microdot/microdot.py @@ -0,0 +1,1175 @@ +""" +microdot +-------- + +The ``microdot`` module defines a few classes that help implement HTTP-based +servers for MicroPython and standard Python, with multithreading support for +Python interpreters that support it. +""" +try: + from sys import print_exception +except ImportError: # pragma: no cover + import traceback + + def print_exception(exc): + traceback.print_exc() +try: + import uerrno as errno +except ImportError: + import errno + +concurrency_mode = 'threaded' + +try: # pragma: no cover + import threading + + def create_thread(f, *args, **kwargs): + # use the threading module + threading.Thread(target=f, args=args, kwargs=kwargs).start() +except ImportError: # pragma: no cover + def create_thread(f, *args, **kwargs): + # no threads available, call function synchronously + f(*args, **kwargs) + + concurrency_mode = 'sync' + +try: + import ujson as json +except ImportError: + import json + +try: + import ure as re +except ImportError: + import re + +try: + import usocket as socket +except ImportError: + try: + import socket + except ImportError: # pragma: no cover + socket = None + +MUTED_SOCKET_ERRORS = [ + 32, # Broken pipe + 54, # Connection reset by peer + 104, # Connection reset by peer + 128, # Operation on closed socket +] + + +def urldecode_str(s): + s = s.replace('+', ' ') + parts = s.split('%') + if len(parts) == 1: + return s + result = [parts[0]] + for item in parts[1:]: + if item == '': + result.append('%') + else: + code = item[:2] + result.append(chr(int(code, 16))) + result.append(item[2:]) + return ''.join(result) + + +def urldecode_bytes(s): + s = s.replace(b'+', b' ') + parts = s.split(b'%') + if len(parts) == 1: + return s.decode() + result = [parts[0]] + for item in parts[1:]: + if item == b'': + result.append(b'%') + else: + code = item[:2] + result.append(bytes([int(code, 16)])) + result.append(item[2:]) + return b''.join(result).decode() + + +def urlencode(s): + return s.replace('+', '%2B').replace(' ', '+').replace( + '%', '%25').replace('?', '%3F').replace('#', '%23').replace( + '&', '%26').replace('=', '%3D') + + +class NoCaseDict(dict): + """A subclass of dictionary that holds case-insensitive keys. + + :param initial_dict: an initial dictionary of key/value pairs to + initialize this object with. + + Example:: + + >>> d = NoCaseDict() + >>> d['Content-Type'] = 'text/html' + >>> print(d['Content-Type']) + text/html + >>> print(d['content-type']) + text/html + >>> print(d['CONTENT-TYPE']) + text/html + >>> del d['cOnTeNt-TyPe'] + >>> print(d) + {} + """ + def __init__(self, initial_dict=None): + super().__init__(initial_dict or {}) + self.keymap = {k.lower(): k for k in self.keys() if k.lower() != k} + + def __setitem__(self, key, value): + kl = key.lower() + key = self.keymap.get(kl, key) + if kl != key: + self.keymap[kl] = key + super().__setitem__(key, value) + + def __getitem__(self, key): + kl = key.lower() + return super().__getitem__(self.keymap.get(kl, kl)) + + def __delitem__(self, key): + kl = key.lower() + super().__delitem__(self.keymap.get(kl, kl)) + + def __contains__(self, key): + kl = key.lower() + return self.keymap.get(kl, kl) in self.keys() + + def get(self, key, default=None): + kl = key.lower() + return super().get(self.keymap.get(kl, kl), default) + + +def mro(cls): # pragma: no cover + """Return the method resolution order of a class. + + This is a helper function that returns the method resolution order of a + class. It is used by Microdot to find the best error handler to invoke for + the raised exception. + + In CPython, this function returns the ``__mro__`` attribute of the class. + In MicroPython, this function implements a recursive depth-first scanning + of the class hierarchy. + """ + if hasattr(cls, 'mro'): + return cls.__mro__ + + def _mro(cls): + m = [cls] + for base in cls.__bases__: + m += _mro(base) + return m + + mro_list = _mro(cls) + + # If a class appears multiple times (due to multiple inheritance) remove + # all but the last occurence. This matches the method resolution order + # of MicroPython, but not CPython. + mro_pruned = [] + for i in range(len(mro_list)): + base = mro_list.pop(0) + if base not in mro_list: + mro_pruned.append(base) + return mro_pruned + + +class MultiDict(dict): + """A subclass of dictionary that can hold multiple values for the same + key. It is used to hold key/value pairs decoded from query strings and + form submissions. + + :param initial_dict: an initial dictionary of key/value pairs to + initialize this object with. + + Example:: + + >>> d = MultiDict() + >>> d['sort'] = 'name' + >>> d['sort'] = 'email' + >>> print(d['sort']) + 'name' + >>> print(d.getlist('sort')) + ['name', 'email'] + """ + def __init__(self, initial_dict=None): + super().__init__() + if initial_dict: + for key, value in initial_dict.items(): + self[key] = value + + def __setitem__(self, key, value): + if key not in self: + super().__setitem__(key, []) + super().__getitem__(key).append(value) + + def __getitem__(self, key): + return super().__getitem__(key)[0] + + def get(self, key, default=None, type=None): + """Return the value for a given key. + + :param key: The key to retrieve. + :param default: A default value to use if the key does not exist. + :param type: A type conversion callable to apply to the value. + + If the multidict contains more than one value for the requested key, + this method returns the first value only. + + Example:: + + >>> d = MultiDict() + >>> d['age'] = '42' + >>> d.get('age') + '42' + >>> d.get('age', type=int) + 42 + >>> d.get('name', default='noname') + 'noname' + """ + if key not in self: + return default + value = self[key] + if type is not None: + value = type(value) + return value + + def getlist(self, key, type=None): + """Return all the values for a given key. + + :param key: The key to retrieve. + :param type: A type conversion callable to apply to the values. + + If the requested key does not exist in the dictionary, this method + returns an empty list. + + Example:: + + >>> d = MultiDict() + >>> d.getlist('items') + [] + >>> d['items'] = '3' + >>> d.getlist('items') + ['3'] + >>> d['items'] = '56' + >>> d.getlist('items') + ['3', '56'] + >>> d.getlist('items', type=int) + [3, 56] + """ + if key not in self: + return [] + values = super().__getitem__(key) + if type is not None: + values = [type(value) for value in values] + return values + + +class Request(): + """An HTTP request.""" + #: Specify the maximum payload size that is accepted. Requests with larger + #: payloads will be rejected with a 413 status code. Applications can + #: change this maximum as necessary. + #: + #: Example:: + #: + #: Request.max_content_length = 1 * 1024 * 1024 # 1MB requests allowed + max_content_length = 16 * 1024 + + #: Specify the maximum payload size that can be stored in ``body``. + #: Requests with payloads that are larger than this size and up to + #: ``max_content_length`` bytes will be accepted, but the application will + #: only be able to access the body of the request by reading from + #: ``stream``. Set to 0 if you always access the body as a stream. + #: + #: Example:: + #: + #: Request.max_body_length = 4 * 1024 # up to 4KB bodies read + max_body_length = 16 * 1024 + + #: Specify the maximum length allowed for a line in the request. Requests + #: with longer lines will not be correctly interpreted. Applications can + #: change this maximum as necessary. + #: + #: Example:: + #: + #: Request.max_readline = 16 * 1024 # 16KB lines allowed + max_readline = 2 * 1024 + + class G: + pass + + def __init__(self, app, client_addr, method, url, http_version, headers, + body=None, stream=None, sock=None): + #: The application instance to which this request belongs. + self.app = app + #: The address of the client, as a tuple (host, port). + self.client_addr = client_addr + #: The HTTP method of the request. + self.method = method + #: The request URL, including the path and query string. + self.url = url + #: The path portion of the URL. + self.path = url + #: The query string portion of the URL. + self.query_string = None + #: The parsed query string, as a + #: :class:`MultiDict ` object. + self.args = {} + #: A dictionary with the headers included in the request. + self.headers = headers + #: A dictionary with the cookies included in the request. + self.cookies = {} + #: The parsed ``Content-Length`` header. + self.content_length = 0 + #: The parsed ``Content-Type`` header. + self.content_type = None + #: A general purpose container for applications to store data during + #: the life of the request. + self.g = Request.G() + + self.http_version = http_version + if '?' in self.path: + self.path, self.query_string = self.path.split('?', 1) + self.args = self._parse_urlencoded(self.query_string) + + if 'Content-Length' in self.headers: + self.content_length = int(self.headers['Content-Length']) + if 'Content-Type' in self.headers: + self.content_type = self.headers['Content-Type'] + if 'Cookie' in self.headers: + for cookie in self.headers['Cookie'].split(';'): + name, value = cookie.strip().split('=', 1) + self.cookies[name] = value + + self._body = body + self.body_used = False + self._stream = stream + self.stream_used = False + self.sock = sock + self._json = None + self._form = None + self.after_request_handlers = [] + + @staticmethod + def create(app, client_stream, client_addr, client_sock=None): + """Create a request object. + + + :param app: The Microdot application instance. + :param client_stream: An input stream from where the request data can + be read. + :param client_addr: The address of the client, as a tuple. + :param client_sock: The low-level socket associated with the request. + + This method returns a newly created ``Request`` object. + """ + # request line + line = Request._safe_readline(client_stream).strip().decode() + if not line: + return None + method, url, http_version = line.split() + http_version = http_version.split('/', 1)[1] + + # headers + headers = NoCaseDict() + while True: + line = Request._safe_readline(client_stream).strip().decode() + if line == '': + break + header, value = line.split(':', 1) + value = value.strip() + headers[header] = value + + return Request(app, client_addr, method, url, http_version, headers, + stream=client_stream, sock=client_sock) + + def _parse_urlencoded(self, urlencoded): + data = MultiDict() + if len(urlencoded) > 0: + if isinstance(urlencoded, str): + for k, v in [pair.split('=', 1) + for pair in urlencoded.split('&')]: + data[urldecode_str(k)] = urldecode_str(v) + elif isinstance(urlencoded, bytes): # pragma: no branch + for k, v in [pair.split(b'=', 1) + for pair in urlencoded.split(b'&')]: + data[urldecode_bytes(k)] = urldecode_bytes(v) + return data + + @property + def body(self): + """The body of the request, as bytes.""" + if self.stream_used: + raise RuntimeError('Cannot use both stream and body') + if self._body is None: + self._body = b'' + if self.content_length and \ + self.content_length <= Request.max_body_length: + while len(self._body) < self.content_length: + data = self._stream.read( + self.content_length - len(self._body)) + if len(data) == 0: # pragma: no cover + raise EOFError() + self._body += data + self.body_used = True + return self._body + + @property + def stream(self): + """The input stream, containing the request body.""" + if self.body_used: + raise RuntimeError('Cannot use both stream and body') + self.stream_used = True + return self._stream + + @property + def json(self): + """The parsed JSON body, or ``None`` if the request does not have a + JSON body.""" + if self._json is None: + if self.content_type is None: + return None + mime_type = self.content_type.split(';')[0] + if mime_type != 'application/json': + return None + self._json = json.loads(self.body.decode()) + return self._json + + @property + def form(self): + """The parsed form submission body, as a + :class:`MultiDict ` object, or ``None`` if the + request does not have a form submission.""" + if self._form is None: + if self.content_type is None: + return None + mime_type = self.content_type.split(';')[0] + if mime_type != 'application/x-www-form-urlencoded': + return None + self._form = self._parse_urlencoded(self.body) + return self._form + + def after_request(self, f): + """Register a request-specific function to run after the request is + handled. Request-specific after request handlers run at the very end, + after the application's own after request handlers. The function must + take two arguments, the request and response objects. The return value + of the function must be the updated response object. + + Example:: + + @app.route('/') + def index(request): + # register a request-specific after request handler + @req.after_request + def func(request, response): + # ... + return response + + return 'Hello, World!' + """ + self.after_request_handlers.append(f) + return f + + @staticmethod + def _safe_readline(stream): + line = stream.readline(Request.max_readline + 1) + if len(line) > Request.max_readline: + raise ValueError('line too long') + return line + + +class Response(): + """An HTTP response class. + + :param body: The body of the response. If a dictionary or list is given, + a JSON formatter is used to generate the body. If a file-like + object or a generator is given, a streaming response is used. + If a string is given, it is encoded from UTF-8. Else, the + body should be a byte sequence. + :param status_code: The numeric HTTP status code of the response. The + default is 200. + :param headers: A dictionary of headers to include in the response. + :param reason: A custom reason phrase to add after the status code. The + default is "OK" for responses with a 200 status code and + "N/A" for any other status codes. + """ + types_map = { + 'css': 'text/css', + 'gif': 'image/gif', + 'html': 'text/html', + 'jpg': 'image/jpeg', + 'js': 'application/javascript', + 'json': 'application/json', + 'png': 'image/png', + 'txt': 'text/plain', + } + send_file_buffer_size = 1024 + + #: The content type to use for responses that do not explicitly define a + #: ``Content-Type`` header. + default_content_type = 'text/plain' + + #: Special response used to signal that a response does not need to be + #: written to the client. Used to exit WebSocket connections cleanly. + already_handled = None + + def __init__(self, body='', status_code=200, headers=None, reason=None): + if body is None and status_code == 200: + body = '' + status_code = 204 + self.status_code = status_code + self.headers = NoCaseDict(headers or {}) + self.reason = reason + if isinstance(body, (dict, list)): + self.body = json.dumps(body).encode() + self.headers['Content-Type'] = 'application/json; charset=UTF-8' + elif isinstance(body, str): + self.body = body.encode() + else: + # this applies to bytes, file-like objects or generators + self.body = body + + def set_cookie(self, cookie, value, path=None, domain=None, expires=None, + max_age=None, secure=False, http_only=False): + """Add a cookie to the response. + + :param cookie: The cookie's name. + :param value: The cookie's value. + :param path: The cookie's path. + :param domain: The cookie's domain. + :param expires: The cookie expiration time, as a ``datetime`` object + or a correctly formatted string. + :param max_age: The cookie's ``Max-Age`` value. + :param secure: The cookie's ``secure`` flag. + :param http_only: The cookie's ``HttpOnly`` flag. + """ + http_cookie = '{cookie}={value}'.format(cookie=cookie, value=value) + if path: + http_cookie += '; Path=' + path + if domain: + http_cookie += '; Domain=' + domain + if expires: + if isinstance(expires, str): + http_cookie += '; Expires=' + expires + else: + http_cookie += '; Expires=' + expires.strftime( + '%a, %d %b %Y %H:%M:%S GMT') + if max_age: + http_cookie += '; Max-Age=' + str(max_age) + if secure: + http_cookie += '; Secure' + if http_only: + http_cookie += '; HttpOnly' + if 'Set-Cookie' in self.headers: + self.headers['Set-Cookie'].append(http_cookie) + else: + self.headers['Set-Cookie'] = [http_cookie] + + def complete(self): + if isinstance(self.body, bytes) and \ + 'Content-Length' not in self.headers: + self.headers['Content-Length'] = str(len(self.body)) + if 'Content-Type' not in self.headers: + self.headers['Content-Type'] = self.default_content_type + if 'charset=' not in self.headers['Content-Type']: + self.headers['Content-Type'] += '; charset=UTF-8' + + def write(self, stream): + self.complete() + + # status code + reason = self.reason if self.reason is not None else \ + ('OK' if self.status_code == 200 else 'N/A') + stream.write('HTTP/1.0 {status_code} {reason}\r\n'.format( + status_code=self.status_code, reason=reason).encode()) + + # headers + for header, value in self.headers.items(): + values = value if isinstance(value, list) else [value] + for value in values: + stream.write('{header}: {value}\r\n'.format( + header=header, value=value).encode()) + stream.write(b'\r\n') + + # body + can_flush = hasattr(stream, 'flush') + try: + for body in self.body_iter(): + if isinstance(body, str): # pragma: no cover + body = body.encode() + stream.write(body) + if can_flush: # pragma: no cover + stream.flush() + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS: + pass + else: + raise + + def body_iter(self): + if self.body: + if hasattr(self.body, 'read'): + while True: + buf = self.body.read(self.send_file_buffer_size) + if len(buf): + yield buf + if len(buf) < self.send_file_buffer_size: + break + if hasattr(self.body, 'close'): # pragma: no cover + self.body.close() + elif hasattr(self.body, '__next__'): + yield from self.body + else: + yield self.body + + @classmethod + def redirect(cls, location, status_code=302): + """Return a redirect response. + + :param location: The URL to redirect to. + :param status_code: The 3xx status code to use for the redirect. The + default is 302. + """ + if '\x0d' in location or '\x0a' in location: + raise ValueError('invalid redirect URL') + return cls(status_code=status_code, headers={'Location': location}) + + @classmethod + def send_file(cls, filename, status_code=200, content_type=None): + """Send file contents in a response. + + :param filename: The filename of the file. + :param status_code: The 3xx status code to use for the redirect. The + default is 302. + :param content_type: The ``Content-Type`` header to use in the + response. If omitted, it is generated + automatically from the file extension. + + Security note: The filename is assumed to be trusted. Never pass + filenames provided by the user without validating and sanitizing them + first. + """ + if content_type is None: + ext = filename.split('.')[-1] + if ext in Response.types_map: + content_type = Response.types_map[ext] + else: + content_type = 'application/octet-stream' + f = open(filename, 'rb') + return cls(body=f, status_code=status_code, + headers={'Content-Type': content_type}) + + +class URLPattern(): + def __init__(self, url_pattern): + self.url_pattern = url_pattern + self.pattern = '' + self.args = [] + use_regex = False + for segment in url_pattern.lstrip('/').split('/'): + if segment and segment[0] == '<': + if segment[-1] != '>': + raise ValueError('invalid URL pattern') + segment = segment[1:-1] + if ':' in segment: + type_, name = segment.rsplit(':', 1) + else: + type_ = 'string' + name = segment + if type_ == 'string': + pattern = '[^/]+' + elif type_ == 'int': + pattern = '\\d+' + elif type_ == 'path': + pattern = '.+' + elif type_.startswith('re:'): + pattern = type_[3:] + else: + raise ValueError('invalid URL segment type') + use_regex = True + self.pattern += '/({pattern})'.format(pattern=pattern) + self.args.append({'type': type_, 'name': name}) + else: + self.pattern += '/{segment}'.format(segment=segment) + if use_regex: + self.pattern = re.compile('^' + self.pattern + '$') + + def match(self, path): + if isinstance(self.pattern, str): + if path != self.pattern: + return + return {} + g = self.pattern.match(path) + if not g: + return + args = {} + i = 1 + for arg in self.args: + value = g.group(i) + if arg['type'] == 'int': + value = int(value) + args[arg['name']] = value + i += 1 + return args + + +class HTTPException(Exception): + def __init__(self, status_code, reason=None): + self.status_code = status_code + self.reason = reason or str(status_code) + ' error' + + def __repr__(self): # pragma: no cover + return 'HTTPException: {}'.format(self.status_code) + + +class Microdot(): + """An HTTP application class. + + This class implements an HTTP application instance and is heavily + influenced by the ``Flask`` class of the Flask framework. It is typically + declared near the start of the main application script. + + Example:: + + from microdot import Microdot + + app = Microdot() + """ + + def __init__(self): + self.url_map = [] + self.before_request_handlers = [] + self.after_request_handlers = [] + self.error_handlers = {} + self.shutdown_requested = False + self.debug = False + self.server = None + + def route(self, url_pattern, methods=None): + """Decorator that is used to register a function as a request handler + for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + :param methods: The list of HTTP methods to be handled by the + decorated function. If omitted, only ``GET`` requests + are handled. + + The URL pattern can be a static path (for example, ``/users`` or + ``/api/invoices/search``) or a path with dynamic components enclosed + in ``<`` and ``>`` (for example, ``/users/`` or + ``/invoices//products``). Dynamic path components can also + include a type prefix, separated from the name with a colon (for + example, ``/users/``). The type can be ``string`` (the + default), ``int``, ``path`` or ``re:[regular-expression]``. + + The first argument of the decorated function must be + the request object. Any path arguments that are specified in the URL + pattern are passed as keyword arguments. The return value of the + function must be a :class:`Response` instance, or the arguments to + be passed to this class. + + Example:: + + @app.route('/') + def index(request): + return 'Hello, world!' + """ + def decorated(f): + self.url_map.append( + (methods or ['GET'], URLPattern(url_pattern), f)) + return f + return decorated + + def get(self, url_pattern): + """Decorator that is used to register a function as a ``GET`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['GET']``. + + Example:: + + @app.get('/users/') + def get_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['GET']) + + def post(self, url_pattern): + """Decorator that is used to register a function as a ``POST`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the``route`` decorator with + ``methods=['POST']``. + + Example:: + + @app.post('/users') + def create_user(request): + # ... + """ + return self.route(url_pattern, methods=['POST']) + + def put(self, url_pattern): + """Decorator that is used to register a function as a ``PUT`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['PUT']``. + + Example:: + + @app.put('/users/') + def edit_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['PUT']) + + def patch(self, url_pattern): + """Decorator that is used to register a function as a ``PATCH`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['PATCH']``. + + Example:: + + @app.patch('/users/') + def edit_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['PATCH']) + + def delete(self, url_pattern): + """Decorator that is used to register a function as a ``DELETE`` + request handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['DELETE']``. + + Example:: + + @app.delete('/users/') + def delete_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['DELETE']) + + def before_request(self, f): + """Decorator to register a function to run before each request is + handled. The decorated function must take a single argument, the + request object. + + Example:: + + @app.before_request + def func(request): + # ... + """ + self.before_request_handlers.append(f) + return f + + def after_request(self, f): + """Decorator to register a function to run after each request is + handled. The decorated function must take two arguments, the request + and response objects. The return value of the function must be an + updated response object. + + Example:: + + @app.after_request + def func(request, response): + # ... + return response + """ + self.after_request_handlers.append(f) + return f + + def errorhandler(self, status_code_or_exception_class): + """Decorator to register a function as an error handler. Error handler + functions for numeric HTTP status codes must accept a single argument, + the request object. Error handler functions for Python exceptions + must accept two arguments, the request object and the exception + object. + + :param status_code_or_exception_class: The numeric HTTP status code or + Python exception class to + handle. + + Examples:: + + @app.errorhandler(404) + def not_found(request): + return 'Not found' + + @app.errorhandler(RuntimeError) + def runtime_error(request, exception): + return 'Runtime error' + """ + def decorated(f): + self.error_handlers[status_code_or_exception_class] = f + return f + return decorated + + def mount(self, subapp, url_prefix=''): + """Mount a sub-application, optionally under the given URL prefix. + + :param subapp: The sub-application to mount. + :param url_prefix: The URL prefix to mount the application under. + """ + for methods, pattern, handler in subapp.url_map: + self.url_map.append( + (methods, URLPattern(url_prefix + pattern.url_pattern), + handler)) + for handler in subapp.before_request_handlers: + self.before_request_handlers.append(handler) + for handler in subapp.after_request_handlers: + self.after_request_handlers.append(handler) + for status_code, handler in subapp.error_handlers.items(): + self.error_handlers[status_code] = handler + + @staticmethod + def abort(status_code, reason=None): + """Abort the current request and return an error response with the + given status code. + + :param status_code: The numeric status code of the response. + :param reason: The reason for the response, which is included in the + response body. + + Example:: + + from microdot import abort + + @app.route('/users/') + def get_user(id): + user = get_user_by_id(id) + if user is None: + abort(404) + return user.to_dict() + """ + raise HTTPException(status_code, reason) + + def run(self, host='0.0.0.0', port=5000, debug=False, ssl=None): + """Start the web server. This function does not normally return, as + the server enters an endless listening loop. The :func:`shutdown` + function provides a method for terminating the server gracefully. + + :param host: The hostname or IP address of the network interface that + will be listening for requests. A value of ``'0.0.0.0'`` + (the default) indicates that the server should listen for + requests on all the available interfaces, and a value of + ``127.0.0.1`` indicates that the server should listen + for requests only on the internal networking interface of + the host. + :param port: The port number to listen for requests. The default is + port 5000. + :param debug: If ``True``, the server logs debugging information. The + default is ``False``. + :param ssl: An ``SSLContext`` instance or ``None`` if the server should + not use TLS. The default is ``None``. + + Example:: + + from microdot import Microdot + + app = Microdot() + + @app.route('/') + def index(): + return 'Hello, world!' + + app.run(debug=True) + """ + self.debug = debug + self.shutdown_requested = False + + self.server = socket.socket() + ai = socket.getaddrinfo(host, port) + addr = ai[0][-1] + + if self.debug: # pragma: no cover + print('Starting {mode} server on {host}:{port}...'.format( + mode=concurrency_mode, host=host, port=port)) + self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.server.bind(addr) + self.server.listen(5) + + if ssl: + self.server = ssl.wrap_socket(self.server, server_side=True) + + while not self.shutdown_requested: + try: + sock, addr = self.server.accept() + except OSError as exc: # pragma: no cover + if exc.errno == errno.ECONNABORTED: + break + else: + print_exception(exc) + except Exception as exc: # pragma: no cover + print_exception(exc) + else: + create_thread(self.handle_request, sock, addr) + + def shutdown(self): + """Request a server shutdown. The server will then exit its request + listening loop and the :func:`run` function will return. This function + can be safely called from a route handler, as it only schedules the + server to terminate as soon as the request completes. + + Example:: + + @app.route('/shutdown') + def shutdown(request): + request.app.shutdown() + return 'The server is shutting down...' + """ + self.shutdown_requested = True + + def find_route(self, req): + f = 404 + for route_methods, route_pattern, route_handler in self.url_map: + req.url_args = route_pattern.match(req.path) + if req.url_args is not None: + if req.method in route_methods: + f = route_handler + break + else: + f = 405 + return f + + def handle_request(self, sock, addr): + if not hasattr(sock, 'readline'): # pragma: no cover + stream = sock.makefile("rwb") + else: + stream = sock + + req = None + res = None + try: + req = Request.create(self, stream, addr, sock) + res = self.dispatch_request(req) + except Exception as exc: # pragma: no cover + print_exception(exc) + try: + if res and res != Response.already_handled: # pragma: no branch + res.write(stream) + stream.close() + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS: + pass + else: + print_exception(exc) + except Exception as exc: # pragma: no cover + print_exception(exc) + if stream != sock: # pragma: no cover + sock.close() + if self.shutdown_requested: # pragma: no cover + self.server.close() + if self.debug and req: # pragma: no cover + print('{method} {path} {status_code}'.format( + method=req.method, path=req.path, + status_code=res.status_code)) + + def dispatch_request(self, req): + if req: + if req.content_length > req.max_content_length: + if 413 in self.error_handlers: + res = self.error_handlers[413](req) + else: + res = 'Payload too large', 413 + else: + f = self.find_route(req) + try: + res = None + if callable(f): + for handler in self.before_request_handlers: + res = handler(req) + if res: + break + if res is None: + res = f(req, **req.url_args) + if isinstance(res, tuple): + body = res[0] + if isinstance(res[1], int): + status_code = res[1] + headers = res[2] if len(res) > 2 else {} + else: + status_code = 200 + headers = res[1] + res = Response(body, status_code, headers) + elif not isinstance(res, Response): + res = Response(res) + for handler in self.after_request_handlers: + res = handler(req, res) or res + for handler in req.after_request_handlers: + res = handler(req, res) or res + elif f in self.error_handlers: + res = self.error_handlers[f](req) + else: + res = 'Not found', f + except HTTPException as exc: + if exc.status_code in self.error_handlers: + res = self.error_handlers[exc.status_code](req) + else: + res = exc.reason, exc.status_code + except Exception as exc: + print_exception(exc) + exc_class = None + res = None + if exc.__class__ in self.error_handlers: + exc_class = exc.__class__ + else: + for c in mro(exc.__class__)[1:]: + if c in self.error_handlers: + exc_class = c + break + if exc_class: + try: + res = self.error_handlers[exc_class](req, exc) + except Exception as exc2: # pragma: no cover + print_exception(exc2) + if res is None: + if 500 in self.error_handlers: + res = self.error_handlers[500](req) + else: + res = 'Internal server error', 500 + else: + if 400 in self.error_handlers: + res = self.error_handlers[400](req) + else: + res = 'Bad request', 400 + + if isinstance(res, tuple): + res = Response(*res) + elif not isinstance(res, Response): + res = Response(res) + return res + + +abort = Microdot.abort +Response.already_handled = Response() +redirect = Response.redirect +send_file = Response.send_file diff --git a/microdot/microdot_utemplate.py b/microdot/microdot_utemplate.py new file mode 100644 index 0000000..ccef608 --- /dev/null +++ b/microdot/microdot_utemplate.py @@ -0,0 +1,34 @@ +from utemplate import recompile + +_loader = None + + +def init_templates(template_dir='templates', loader_class=recompile.Loader): + """Initialize the templating subsystem. + + :param template_dir: the directory where templates are stored. This + argument is optional. The default is to load templates + from a *templates* subdirectory. + :param loader_class: the ``utemplate.Loader`` class to use when loading + templates. This argument is optional. The default is + the ``recompile.Loader`` class, which automatically + recompiles templates when they change. + """ + global _loader + _loader = loader_class(None, template_dir) + + +def render_template(template, *args, **kwargs): + """Render a template. + + :param template: The filename of the template to render, relative to the + configured template directory. + :param args: Positional arguments to be passed to the render engine. + :param kwargs: Keyword arguments to be passed to the render engine. + + The return value is an iterator that returns sections of rendered template. + """ + if _loader is None: # pragma: no cover + init_templates() + render = _loader.load(template) + return render(*args, **kwargs) From 02079866235265e73af32a8b226ed0856a82d616 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Fri, 17 Feb 2023 09:52:03 +0100 Subject: [PATCH 3/8] remove picoweb from required packages, add microdot folder in setup.py --- setup.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 8c7308a..0ed9e91 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,10 @@ }, license='MIT', cmdclass={'sdist': sdist_upip.sdist}, - packages=['wifi_manager'], + packages=[ + 'wifi_manager', + 'microdot', + ], # Although 'package_data' is the preferred approach, in some case you may # need to place data files outside of your packages. See: # http://docs.python.org/distutils/setupscript.html#installing-additional-files @@ -61,7 +64,6 @@ ) ], install_requires=[ - 'picoweb', 'micropython-ulogging', 'micropython-brainelectronics-helpers', 'utemplate', From 17f937ec9c215f6dcff77f9ab69308db0c0c418f Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Fri, 17 Feb 2023 09:53:20 +0100 Subject: [PATCH 4/8] add favicon to package --- setup.py | 1 + static/favicon.ico | Bin 0 -> 8450 bytes templates/index.tpl | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 static/favicon.ico diff --git a/setup.py b/setup.py index 0ed9e91..49488a2 100644 --- a/setup.py +++ b/setup.py @@ -50,6 +50,7 @@ [ 'static/css/bootstrap.min.css', 'static/css/bootstrap.min.css.gz', + 'static/favicon.ico', 'static/js/toast.js', 'static/js/toast.js.gz', ] diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b151f0b8afade8785147dbe0bc5deb92e2a40c36 GIT binary patch literal 8450 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 literal 0 HcmV?d00001 diff --git a/templates/index.tpl b/templates/index.tpl index 6844eb4..1fa3c63 100644 --- a/templates/index.tpl +++ b/templates/index.tpl @@ -8,7 +8,7 @@ Setup - +