🎉 first commit

This commit is contained in:
Joe Kaufeld 2024-07-28 20:46:42 -04:00
parent f988ba32f8
commit 7f88c01156
9 changed files with 392 additions and 1 deletions

2
.gitignore vendored
View file

@ -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/

111
poetry.lock generated Normal file
View file

@ -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"

18
pyproject.toml Normal file
View file

@ -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 <opensource@joekaufeld.com>"]
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"

0
spiderweb/__init__.py Normal file
View file

31
spiderweb/converters.py Normal file
View file

@ -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)

0
spiderweb/db.py Normal file
View file

29
spiderweb/exceptions.py Normal file
View file

@ -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

202
spiderweb/main.py Normal file
View file

@ -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}")

0
spiderweb/middleware.py Normal file
View file