🎉 first commit
This commit is contained in:
parent
f988ba32f8
commit
7f88c01156
9 changed files with 392 additions and 1 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -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
111
poetry.lock
generated
Normal 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
18
pyproject.toml
Normal 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
0
spiderweb/__init__.py
Normal file
31
spiderweb/converters.py
Normal file
31
spiderweb/converters.py
Normal 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
0
spiderweb/db.py
Normal file
29
spiderweb/exceptions.py
Normal file
29
spiderweb/exceptions.py
Normal 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
202
spiderweb/main.py
Normal 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
0
spiderweb/middleware.py
Normal file
Loading…
Add table
Reference in a new issue