From 7f88c01156264b8308ab8bd423721e2fce3e6d4a Mon Sep 17 00:00:00 2001 From: Joe Kaufeld Date: Sun, 28 Jul 2024 20:46:42 -0400 Subject: [PATCH] :tada: first commit --- .gitignore | 2 +- poetry.lock | 111 ++++++++++++++++++++++ pyproject.toml | 18 ++++ spiderweb/__init__.py | 0 spiderweb/converters.py | 31 ++++++ spiderweb/db.py | 0 spiderweb/exceptions.py | 29 ++++++ spiderweb/main.py | 202 ++++++++++++++++++++++++++++++++++++++++ spiderweb/middleware.py | 0 9 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 spiderweb/__init__.py create mode 100644 spiderweb/converters.py create mode 100644 spiderweb/db.py create mode 100644 spiderweb/exceptions.py create mode 100644 spiderweb/main.py create mode 100644 spiderweb/middleware.py diff --git a/.gitignore b/.gitignore index 5d381cc..f295d3d 100644 --- a/.gitignore +++ b/.gitignore @@ -158,5 +158,5 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..51cdfb4 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,111 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "peewee" +version = "3.17.6" +description = "a little orm" +optional = false +python-versions = "*" +files = [ + {file = "peewee-3.17.6.tar.gz", hash = "sha256:cea5592c6f4da1592b7cff8eaf655be6648a1f5857469e30037bf920c03fb8fb"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.3.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, + {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "ruff" +version = "0.5.5" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.5.5-py3-none-linux_armv6l.whl", hash = "sha256:605d589ec35d1da9213a9d4d7e7a9c761d90bba78fc8790d1c5e65026c1b9eaf"}, + {file = "ruff-0.5.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00817603822a3e42b80f7c3298c8269e09f889ee94640cd1fc7f9329788d7bf8"}, + {file = "ruff-0.5.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:187a60f555e9f865a2ff2c6984b9afeffa7158ba6e1eab56cb830404c942b0f3"}, + {file = "ruff-0.5.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe26fc46fa8c6e0ae3f47ddccfbb136253c831c3289bba044befe68f467bfb16"}, + {file = "ruff-0.5.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ad25dd9c5faac95c8e9efb13e15803cd8bbf7f4600645a60ffe17c73f60779b"}, + {file = "ruff-0.5.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f70737c157d7edf749bcb952d13854e8f745cec695a01bdc6e29c29c288fc36e"}, + {file = "ruff-0.5.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:cfd7de17cef6ab559e9f5ab859f0d3296393bc78f69030967ca4d87a541b97a0"}, + {file = "ruff-0.5.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a09b43e02f76ac0145f86a08e045e2ea452066f7ba064fd6b0cdccb486f7c3e7"}, + {file = "ruff-0.5.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0b856cb19c60cd40198be5d8d4b556228e3dcd545b4f423d1ad812bfdca5884"}, + {file = "ruff-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3687d002f911e8a5faf977e619a034d159a8373514a587249cc00f211c67a091"}, + {file = "ruff-0.5.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ac9dc814e510436e30d0ba535f435a7f3dc97f895f844f5b3f347ec8c228a523"}, + {file = "ruff-0.5.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:af9bdf6c389b5add40d89b201425b531e0a5cceb3cfdcc69f04d3d531c6be74f"}, + {file = "ruff-0.5.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d40a8533ed545390ef8315b8e25c4bb85739b90bd0f3fe1280a29ae364cc55d8"}, + {file = "ruff-0.5.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cab904683bf9e2ecbbe9ff235bfe056f0eba754d0168ad5407832928d579e7ab"}, + {file = "ruff-0.5.5-py3-none-win32.whl", hash = "sha256:696f18463b47a94575db635ebb4c178188645636f05e934fdf361b74edf1bb2d"}, + {file = "ruff-0.5.5-py3-none-win_amd64.whl", hash = "sha256:50f36d77f52d4c9c2f1361ccbfbd09099a1b2ea5d2b2222c586ab08885cf3445"}, + {file = "ruff-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3191317d967af701f1b73a31ed5788795936e423b7acce82a2b63e26eb3e89d6"}, + {file = "ruff-0.5.5.tar.gz", hash = "sha256:cc5516bdb4858d972fbc31d246bdb390eab8df1a26e2353be2dbc0c2d7f5421a"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "8c6e729478f063b2dfe51e54aae69b0577ea66599cf77201928dbdcc5fc3f688" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2b7a5f8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "spiderweb" +version = "0.1.0" +description = "A small web framework, just big enough to hold your average spider." +authors = ["Joe Kaufeld "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" +peewee = "^3.17.6" + +[tool.poetry.group.dev.dependencies] +ruff = "^0.5.5" +pytest = "^8.3.2" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/spiderweb/__init__.py b/spiderweb/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/spiderweb/converters.py b/spiderweb/converters.py new file mode 100644 index 0000000..c346ef0 --- /dev/null +++ b/spiderweb/converters.py @@ -0,0 +1,31 @@ +class IntConverter: + regex = r"\d+" + name = "int" + + def to_python(self, value): + return int(value) + + def to_url(self, value): + return str(value) + + +class StrConverter: + regex = r"[^/]+" + name = "str" + + def to_python(self, value): + return str(value) + + def to_url(self, value): + return str(value) + + +class FloatConverter: + regex = r"\d+\.\d+" + name = "float" + + def to_python(self, value): + return float(value) + + def to_url(self, value): + return str(value) \ No newline at end of file diff --git a/spiderweb/db.py b/spiderweb/db.py new file mode 100644 index 0000000..e69de29 diff --git a/spiderweb/exceptions.py b/spiderweb/exceptions.py new file mode 100644 index 0000000..fc0eaf4 --- /dev/null +++ b/spiderweb/exceptions.py @@ -0,0 +1,29 @@ +class SpiderwebException(Exception): + # parent error class; all child exceptions should inherit from this + pass + + +class SpiderwebNetworkException(SpiderwebException): + def __init__(self, code, msg=None, desc=None): + self.code = code + self.msg = msg + self.desc = desc + + def __str__(self): + return f"{self.__class__.__name__}({self.code}, {self.msg})" + + +class APIError(SpiderwebNetworkException): + pass + + +class ConfigError(SpiderwebException): + pass + + +class ParseError(SpiderwebException): + pass + + +class GeneralException(SpiderwebException): + pass \ No newline at end of file diff --git a/spiderweb/main.py b/spiderweb/main.py new file mode 100644 index 0000000..478414f --- /dev/null +++ b/spiderweb/main.py @@ -0,0 +1,202 @@ +# very simple RPC server in python +# Originally from https://gist.github.com/earonesty/ab07b4c0fea2c226e75b3d538cc0dc55 +# Extensively modified by @itsthejoker + +import json +import re +from http.server import BaseHTTPRequestHandler, HTTPServer +import urllib.parse as urlparse +import threading +import logging +from typing import Callable, Any + +from spiderweb.converters import * # noqa: F403 +from spiderweb.exceptions import APIError, ConfigError, ParseError, GeneralException + +log = logging.getLogger(__name__) + + +def api_route(path): + def outer(func): + if not hasattr(func, "_routes"): + setattr(func, "_routes", []) + func._routes += [path] + return func + + return outer + + +def convert_path(path): + """Convert a path to a regex.""" + parts = path.split("/") + for i, part in enumerate(parts): + if part.startswith("<") and part.endswith(">"): + name = part[1:-1] + if "__" in name: + raise ConfigError( + f"Cannot use `__` (double underscore) in path variable." + f" Please fix '{name}'." + ) + if ":" in name: + converter, name = name.split(":") + try: + converter = globals()[converter.title() + "Converter"] + except KeyError: + raise ParseError(f"Unknown converter {converter}") + else: + converter = StrConverter # noqa: F405 + parts[i] = r"(?P<%s>%s)" % ( + f"{name}__{str(converter.__name__)}", + converter.regex, + ) + return re.compile(r"^%s$" % "/".join(parts)) + + +def convert_match_to_dict(match: dict): + """Convert a match object to a dict with the proper converted types for each match.""" + return { + k.split("__")[0]: globals()[k.split("__")[1]]().to_python(v) + for k, v in match.items() + } + + +class APIServer(HTTPServer): + def __init__(self, addr: str, port: int, custom_handler: Callable = None): + """ + Create a new server on address, port. Port can be zero. + + > from simple_rpc_server import APIServer, APIError, api_route + + Create your handlers by inheriting from APIServer and tagging them with + @api_route("/path"). Alternately, you can use the APIServer() directly + by calling `add_handler("path", function)`. + + Raise network errors by raising `APIError(code, message, description=None)`. + + Return responses by simply returning a dict() or str() object. + + Parameter to handlers is a dict(). + + Query arguments are shoved into the dict via urllib.parse_qs. + """ + server_address = (addr, port) + self.__addr = addr + + # shim class that is an APIHandler + class HandlerClass(APIHandler): + pass + + self.handler_class = custom_handler if custom_handler else HandlerClass + + # routed methods map into handler + for method in type(self).__dict__.values(): + if hasattr(method, "_routes"): + for route in method._routes: + self.add_route(route, method) + + try: + super().__init__(server_address, HandlerClass) + except OSError: + raise GeneralException("Port already in use.") + + def add_route(self, path: str, method: Callable): + self.handler_class._routes[convert_path(path)] = method + + def port(self): + """Return current port.""" + return self.socket.getsockname()[1] + + def address(self): + """Return current IP address.""" + return self.socket.getsockname()[0] + + def uri(self, path): + """Make a URI pointing at myself.""" + if path[0] == "/": + path = path[1:] + return "http://" + self.__addr + ":" + str(self.port()) + "/" + path + + def start(self, blocking=False): + if not blocking: + threading.Thread(target=self.serve_forever).start() + else: + try: + self.serve_forever() + except KeyboardInterrupt: + print() # empty line after ^C + print("Stopping server!") + return + + def shutdown(self): + super().shutdown() + self.socket.close() + + +class APIHandler(BaseHTTPRequestHandler): + # I can't help the naming convention of these because that's what + # BaseHTTPRequestHandler uses for some weird reason + _routes = {} + + def do_GET(self): + self.do_action() + + def do_POST(self): + content = "{}" + if self.headers["Content-Length"]: + length = int(self.headers["Content-Length"]) + content = self.rfile.read(length) + info = None + if content: + try: + info = json.loads(content) + except json.JSONDecodeError: + raise APIError(400, "Invalid JSON", content) + self.do_action(info) + + def get_route(self, path) -> tuple[Callable, dict[str, Any]]: + for option in self._routes.keys(): + if match_data := option.match(path): + return self._routes[option], convert_match_to_dict( + match_data.groupdict() + ) + raise APIError(404, "No route found") + + def do_action(self, info=None): + info = info or {} + try: + url = urlparse.urlparse(self.path) + + handler, additional_args = self.get_route(url.path) + + if url.query: + params = urlparse.parse_qs(url.query) + else: + params = {} + + info.update(params) + + if handler: + try: + response = handler(info, **additional_args) + self.send_response(200) + if response is None: + response = "" + if isinstance(response, dict): + response = json.dumps(response) + response = bytes(str(response), "utf-8") + self.send_header("Content-Length", str(len(response))) + self.end_headers() + self.wfile.write(response) + except APIError: + raise + except ConnectionAbortedError as e: + log.error(f"GET {self.path} : {e}") + except Exception as e: + raise APIError(500, str(e)) + else: + raise APIError(404) + except APIError as e: + try: + self.send_error(e.code, e.msg, e.desc) + except ConnectionAbortedError as e: + log.error(f"GET {self.path} : {e}") diff --git a/spiderweb/middleware.py b/spiderweb/middleware.py new file mode 100644 index 0000000..e69de29