api changes to dotdict/path and new http_server_async submodule

This commit is contained in:
Izalia Mae 2021-10-23 08:55:43 -04:00
parent 29b5252e3e
commit 3e1cc946fd
14 changed files with 731 additions and 135 deletions

View file

@ -19,14 +19,16 @@ from .path import Path
from .dotdict import DotDict, LowerDotDict, DefaultDotDict, MultiDotDict, JsonEncoder
from .misc import *
from .cache import CacheDecorator, LruCache, TtlCache
from .config import BaseConfig, JsonConfig, YamlConfig
from .connection import Connection
from .http_client import HttpClient, HttpResponse
Config = JsonConfig
def log_import_error(package, *message):
izzylog.debug(*message)
path = Path(__file__).resolve.parent.join(package)
path = Path(__file__).resolve().parent.join(package)
if path.exists and izzylog.get_config('level') == logging.Levels.DEBUG:
traceback.print_exc()

View file

@ -118,21 +118,24 @@ class LowerDotDict(DotDict):
return super().__setitem__(key.lower(), value)
class MultiDotDict(DotDict):
def __getattr__(self, key):
return self.__getitem__(key)
class MultiDotDict(LowerDotDict):
def __setitem__(self, key, value, single=False):
key = key.lower()
if single:
super().__setitem__(key, [value])
def __setitem__(self, key, value):
try:
self.__getitem__(key.lower(), False).append(value)
else:
try:
self.__getitem__(key, False).append(value)
except KeyError as e:
super().__setitem__(key.lower(), [value])
except KeyError as e:
super().__setitem__(key, [value])
def __getitem__(self, key, single=True):
values = super().__getitem__(key.lower())
key = key.lower()
values = super().__getitem__(key)
if single:
try:
@ -169,10 +172,7 @@ class MultiDotDict(DotDict):
def set(self, key, value):
if self.get(key):
del self[key]
self[key] = value
self.__setitem__(key, value, True)
def delone(self, key, value):
@ -180,7 +180,7 @@ class MultiDotDict(DotDict):
def delete(self, key):
self.pop(key)
del self[key]
class JsonEncoder(json.JSONEncoder):

View file

@ -8,3 +8,15 @@ class DBusServerError(Exception):
class HttpFileDownloadedError(Exception):
'raise when a download failed for any reason'
class InvalidMethodException(Exception):
def __init__(self, method):
super().__init__(f'Invalid HTTP method: {method}')
self.method = method
class MethodNotHandledException(Exception):
def __init__(self, method):
super().__init__(f'HTTP method not handled by handler: {method}')
self.method = method

View file

@ -29,7 +29,7 @@ log_ext_ignore = [
'divx', 'mov', 'mp4', 'webm', 'wmv'
]
frontend = Path(__file__).resolve.parent.join('frontend')
frontend = Path(__file__).resolve().parent.join('frontend')
class Application(sanic.Sanic):
_extra = DotDict()
@ -68,8 +68,9 @@ class Application(sanic.Sanic):
self.add_error_handler(MissingTemplateError)
self.add_error_handler(GenericError)
## compat
self.start = self.run
for sig in signal.valid_signals():
if type(sig) != int:
self.set_signal(sig)
def __getattr__(self, key):
@ -138,12 +139,7 @@ class Application(sanic.Sanic):
return handler
def run(self, log=False, async_server=False):
signal.signal(signal.SIGHUP, self.finish)
signal.signal(signal.SIGINT, self.finish)
signal.signal(signal.SIGQUIT, self.finish)
signal.signal(signal.SIGTERM, self.finish)
def start(self, log=False, async_server=False):
# register built-in middleware now so they're last in the chain
self.add_middleware(Headers)
@ -167,7 +163,7 @@ class Application(sanic.Sanic):
return_asyncio_server = True
)
super().run(
self.run(
host = self.cfg.listen,
port = self.cfg.port,
workers = self.cfg.workers,
@ -181,15 +177,41 @@ class Application(sanic.Sanic):
return self.run(async_server=True)
def finish(self, *args):
def set_signal(self, sig):
if type(sig) == int:
return
if sig in [signal.SIGKILL]:
return
try:
signal.signal(sig, partial(self.finish, sig))
except OSError:
pass
def finish(self, sig, *args):
if self.cfg.sig_handler:
self.cfg.sig_handler(self, *self.cfg.sig_handler_args, **self.cfg.sig_handler_kwargs)
self.cfg.sig_handler(self, sig, *self.cfg.sig_handler_args, **self.cfg.sig_handler_kwargs)
self.stop()
try:
self.loop.stop()
self.loop.close()
except Exception as e:
traceback.print_exception(e)
#izzylog.info('Bye! :3')
#sys.exit()
#async def dispatch(self, *args, **kwargs):
#return
def parse_level(level):
if type(level) == int:
level = UserLevel(level)

View file

@ -0,0 +1,10 @@
http_methods = ['CONNECT', 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'TRACE']
from .application import Application
from .cookies import Cookies
from .middleware import Middleware
from .request import Request
from .response import Response
from .router import Router
from .view import View

View file

@ -0,0 +1,196 @@
import asyncio, signal, socket, sys, time, traceback
from functools import partial
from http_router import Router, MethodNotAllowed, NotFound
from . import http_methods, error
from .config import Config
from .response import Response
#from .router import Router
from .. import logging
from ..dotdict import DotDict
from ..exceptions import MethodNotHandledException
class Application:
def __init__(self, loop=None, **kwargs):
self.loop = loop or asyncio.get_event_loop()
self._server = None
self.cfg = Config(**kwargs)
#self.router = Router(trim_last_slash=True)
self.router = Router()
self.middleware = DotDict({'request': [], 'response': []})
#self.add_view = self.router.add_view
#self.add_route = self.router.add_route
#self.add_static = self.router.add_static
#self.get_route = self.router.get_route
signal.signal(signal.SIGHUP, self.stop)
signal.signal(signal.SIGINT, self.stop)
signal.signal(signal.SIGQUIT, self.stop)
signal.signal(signal.SIGTERM, self.stop)
def get_route(self, path, method='GET'):
return self.router(path, method.upper())
def add_route(self, handler, path, method='GET'):
self.router.bind(handler, path, methods=[method.upper()])
def add_view(self, view):
paths = view.__path__ if isinstance(view.__path__, list) else [view.__path__]
view_class = view(self)
for path in paths:
for method in http_methods:
try:
self.add_route(view_class.get_handler(method), path, method)
except MethodNotHandledException:
pass
def add_middleware(self, handler):
if not asyncio.iscoroutinefunction(handler):
raise TypeError('Middleware handler must be a coroutine function or method')
try:
arg_len = len(handler.__code__.co_varnames)
if arg_len == 1:
attach = 'request'
elif arg_len == 2:
attach = 'response'
else:
raise TypeError(f'Middleware handler must have 1 (request) or 2 (response) arguments, not {arg_len}')
except Exception as e:
raise e from None
mwlist = self.middleware[attach]
if handler in mwlist:
return logging.error(f'Middleware handler already added to {attach}: {handler}')
mwlist.append(handler)
def stop(self, *_):
if not self._server:
print('server not running')
return
self._server.close()
def start(self, log=True):
if self._server:
return
if self.cfg.socket:
logging.info(f'Starting server on {self.cfg.socket}')
server = asyncio.start_unix_server(
self.handle_client,
path = self.cfg.socket
)
else:
logging.info(f'Starting server on {self.cfg.listen}:{self.cfg.port}')
server = asyncio.start_server(
self.handle_client,
host = self.cfg.listen,
port = self.cfg.port,
family = socket.AF_INET,
reuse_address = True,
reuse_port = True
)
self._server = self.loop.run_until_complete(server)
self.loop.run_until_complete(self.handle_run_server())
async def handle_run_server(self):
while self._server.is_serving():
await asyncio.sleep(0.1)
await self._server.wait_closed()
self._server = None
logging.info('Server stopped')
async def handle_client(self, reader, writer):
request = None
response = None
try:
request = self.cfg.request_class(self, reader, writer.get_extra_info('peername')[0])
await request.parse_headers()
handler = self.get_route(request.path, request.method)
await self.handle_middleware(request)
if handler.params:
handler_response = await handler.target(request, **handler.params)
else:
handler_response = await handler.target(request)
if isinstance(handler_response, dict):
response = self.cfg.response_class(**handler_response)
elif isinstance(handler_response, Response):
response = handler_response
else:
raise error.ServerError()
except NotFound:
response = self.cfg.response_class.error('Not Found', 404)
except MethodNotAllowed:
response = self.cfg.response_class.error('Method Not Allowed', 405)
except error.HttpError as e:
response = self.cfg.response_class.error(e.message, e.status)
except:
traceback.print_exc()
response = self.cfg.response_class.error('Server Error', 500)
try:
await self.handle_middleware(request, response)
except:
traceback.print_exc()
response = Response.error('Server Error', 500)
response.headers.update(self.cfg.default_headers)
writer.write(response.compile())
await writer.drain()
writer.close()
await writer.wait_closed()
if request:
logging.info(f'{request.remote} {request.method} {request.path} {response.status} {len(response.body)} {request.agent}')
async def handle_middleware(self, request, response=None):
for middleware in self.middleware['response' if response else 'request']:
if response:
await middleware(request, response)
else:
await middleware(request)

View file

@ -0,0 +1,85 @@
from .request import Request
from .response import Response
from .. import __version__
from ..config import BaseConfig
from ..misc import boolean
class Config(BaseConfig):
_startup = True
def __init__(self, **kwargs):
super().__init__(
name = 'IzzyLib Http Server',
title = None,
version = '0.0.1',
git_repo = 'https://git.barkshark.xyz/izaliamae/izzylib',
socket = None,
listen = 'localhost',
host = None,
web_host = None,
alt_hosts = [],
port = 8080,
proto = 'http',
access_log = True,
timeout = 60,
default_headers = {},
request_class = Request,
response_class = Response,
sig_handler = None,
sig_handler_args = [],
sig_handler_kwargs = {},
tpl_search = [],
tpl_globals = {},
tpl_context = None,
tpl_autoescape = True,
tpl_default = True
)
self._startup = False
self.set_data(kwargs)
self.default_headers['server'] = f'{self.name}/{__version__}'
def parse_value(self, key, value):
if self._startup:
return value
if key == 'listen':
if not self.host:
self.host = value
if not self.web_host:
self.web_host = value
elif key == 'host':
if not self.web_host or self.web_host == self.listen:
self.web_host = value
elif key == 'port' and not isinstance(value, int):
raise TypeError(f'{key} must be an integer')
elif key == 'socket':
value = Path(value)
elif key in ['access_log', 'tpl_autoescape', 'tpl_default'] and not isinstance(value, bool):
raise TypeError(f'{key} must be a boolean')
elif key in ['alt_hosts', 'sig_handler_args', 'tpl_search'] and not isinstance(value, list):
raise TypeError(f'{key} must be a list')
elif key in ['sig_handler_kwargs', 'tpl_globals'] and not isinstance(value, dict):
raise TypeError(f'{key} must be a dict')
elif key == 'tpl_context' and not getattr(value, '__call__', None):
raise TypeError(f'{key} must be a callable')
elif key == 'request_class' and not isinstance(value, Request):
raise TypeError(f'{key} must be a subclass of izzylib.http_server_async.Request')
elif key == 'response_class' and not isinstance(value, Response):
raise TypeError(f'{key} must be a subclass of izzylib.http_server_async.Response')
return value

View file

@ -0,0 +1,36 @@
class HttpError(Exception):
def __init__(self, message, status=500):
super().__init__(f'HTTP Error {status}: {message}')
self.status = status
self.message = message
class Unauthorized(HttpError):
def __init__(self, message='Unauthorized'):
super().__init__(message, 401)
class Forbidden(HttpError):
def __init__(self, message='Forbidden'):
super().__init__(message, 403)
class NotFound(HttpError):
def __init__(self, message='Not Found'):
super().__init__(message, 404)
class MethodNotAllowed(HttpError):
def __init__(self, message='Method Not Allowed'):
super().__init__(message, 405)
class Teapot(HttpError):
def __init__(self, message='I am a teapot'):
super().__init__(message, 418)
class ServerError(HttpError):
def __init__(self, message='ServerError'):
super().__init__(message, 500)

View file

@ -0,0 +1,147 @@
import asyncio, email, traceback
from datetime import datetime, timezone
from urllib.parse import unquote_plus
from ..dotdict import DotDict, MultiDotDict
UtcTime = timezone.utc
LocalTime = datetime.now(UtcTime).astimezone().tzinfo
class Request:
__slots__ = ['_body', '_form', '_reader', 'app', 'address', 'method', 'path', 'version', 'headers', 'query', 'raw_query', 'ctx']
def __init__(self, app, reader, address):
super().__init__()
self._reader = reader
self._body = b''
self._form = DotDict()
self.app = app
self.address = address
self.method = None
self.path = None
self.version = None
self.headers = MultiDotDict()
self.query = DotDict()
self.raw_query = None
self.ctx = DotDict()
def __getitem__(self, key):
return self.ctx[key]
def __setitem__(self, key, value):
self.ctx[key] = value
def __getattr__(self, key):
return self.ctx[key]
def __setattr__(self, key, value):
if key not in self.__slots__:
self.ctx[key] = value
else:
super().__setattr__(key, value)
@property
def agent(self):
return self.headers.get('User-Agent', 'no agent')
@property
def content_type(self):
return self.headers.get('Content-Type', '')
@property
def date(self):
date_str = self.headers.get('Date')
if date_str:
date = datetime.strptime(date_str, '%a, %d %b %Y %H:%M:%S GMT')
date = date.replace(tzinfo=UtcTime)
return date.astimezone(LocalTime)
# not sure if this should stay
return datetime.now(LocalTime)
@property
def host(self):
return self.headers.get('Host')
@property
def length(self):
return int(self.headers.get('Content-Length', 0))
@property
def remote(self):
return self.headers.get('X-Real-Ip', self.headers.get('X-Forwarded-For', self.address))
async def read(self, length=2048, timeout=None):
try: return await asyncio.wait_for(self._reader.read(length), timeout or self.app.cfg.timeout)
except: return
async def body(self):
if not self._body and self.length:
self._body = await self.read(self.length)
return self._body
async def text(self):
return (await self.body()).decode('utf-8')
async def dict(self):
return DotDict(await self.body())
async def form(self):
if not self._form and 'application/x-www-form-urlencoded' in self.content_type:
for line in unquote_plus(await self.text()).split('&'):
try: key, value = line.split('=', 1)
except: key, value = line, None
self._form[key] = value
return self._form
async def parse_headers(self):
data = (await self._reader.readuntil(b'\r\n\r\n')).decode('utf-8')
for idx, line in enumerate(data.splitlines()):
if idx == 0:
self.method, path, self.version = line.split()
try: self.path, self.raw_query = path.split('?', 1)
except: self.path = path
if self.raw_query:
for qline in unquote_plus(self.raw_query).split('&'):
try: key, value = qline.split('=')
except: key, value = qline, None
self.query[key] = value
else:
try: key, value = line.split(': ')
except: continue
self.headers[key] = value
def new_response(self, *args, **kwargs):
return self.app.cfg.response_class(*args, **kwargs)

View file

@ -0,0 +1,86 @@
import json
from datetime import datetime
from .cookies import Cookies
from ..dotdict import MultiDotDict
class Response:
__slots__ = ['_body', 'headers', 'cookies', 'status']
def __init__(self, body=b'', status=200, headers={}, cookies={}):
self._body = b''
self.body = body
self.headers = MultiDotDict(headers)
self.cookies = Cookies(cookies)
self.status = status
if not self.headers.get('content-type'):
self.headers['content-type'] = 'text/plain'
@property
def body(self):
return self._body
@body.setter
def body(self, data):
if isinstance(data, bytes):
self._body += data
elif isinstance(data, str):
self._body += data.encode('utf-8')
elif any(map(isinstance, [data], [dict, list, tuple])):
self._body += json.dumps(data).encode('utf-8')
else:
self._body += str(data).encode('utf-8')
@classmethod
def text(cls, *args, **kwargs):
return cls(*args, **kwargs)
@classmethod
def html(cls, *args, headers={}, **kwargs):
if not headers.get('content-type'):
headers['content-type'] = 'text/html'
return cls(*args, headers=headers, **kwargs)
@classmethod
def json(cls, *args, headers={}, **kwargs):
if not headers.get('content-type'):
headers['content-type'] = 'application/json'
return cls(*args, headers=headers, **kwargs)
@classmethod
def error(cls, message, status=500):
return cls(f'HTTP Error {status}: {message}', status=status)
def compile(self):
data = bytes(f'HTTP/1.1 {self.status}', 'utf-8')
for k,v in self.headers.items():
for value in v:
data += bytes(f'\r\n{k.capitalize()}: {value}', 'utf-8')
if not self.headers.get('content-length'):
data += bytes(f'\r\nContent-Length: {len(self.body)}', 'utf-8')
if not self.headers.get('date'):
data += bytes(f'\r\nDate: {datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT")}', 'utf-8')
data += b'\r\n\r\n'
data += self.body
return data

View file

@ -0,0 +1,30 @@
from . import http_methods
from ..exceptions import (
InvalidMethodException,
MethodNotHandledException
)
class View:
__path__ = ''
__slots__ = ['app']
def __init__(self, app):
self.app = app
def get_handler(self, method):
if method.upper() not in http_methods:
raise InvalidMethodException(method)
try:
return getattr(self, method.lower())
except AttributeError:
raise MethodNotHandledException(method)
#def get(self, request):
#pass

View file

@ -30,7 +30,6 @@ __all__ = [
'time_function_pprint',
'timestamp',
'var_name',
'Config',
'Url'
]
@ -466,44 +465,6 @@ def var_name(single=True, **kwargs):
return key[0] if single else keys
class Config(DotDict):
def __init__(self, json_file=None, **defaults):
self._defaults = defaults
self._json = Path(json_file)
super().__init__(defaults)
def __setitem__(self, key, value):
if not key in self._defaults:
raise KeyError(f'Not a valid config option: {key}')
super().__setitem__(key, value)
def reset(self, key=None):
if not key:
self.update(self._defaults)
else:
self[key] = self._defaults[key]
def load(self):
try:
self.load_json(self._json)
return True
except FileNotFoundError:
izzylog.warning('Cannot find path to config file:', self._json)
return False
def save(self, indent='\t'):
self._json.parent.mkdir()
self.save_json(self._json, indent=indent)
class Url(str):
protocols = {
'http': 80,

View file

@ -5,13 +5,24 @@ from functools import cached_property
from pathlib import Path as PyPath
class Path(str):
def __init__(self, path=os.getcwd(), exist=True, missing=True, parents=True):
if str(path).startswith('~'):
str.__new__(Path, os.path.expanduser(path))
class PathMeta(type):
@property
def home(cls):
return cls('~').expanduser()
else:
str.__new__(Path, path)
@property
def cwd(cls):
return cls(os.getcwd()).resolve()
class Path(str, metaclass=PathMeta):
def __init__(self, path=os.getcwd(), exist=True, missing=True, parents=True):
#if str(path).startswith('~'):
#str.__new__(Path, os.path.expanduser(path))
#else:
#str.__new__(Path, path)
self.config = {
'missing': missing,
@ -32,8 +43,11 @@ class Path(str):
return self.join(key)
def __new__(cls, content):
return str.__new__(cls, content)
def __new__(cls, path):
if str(path).startswith('~'):
return str.__new__(cls, os.path.expanduser(path))
return str.__new__(cls, path)
def __check_dir(self, path=None):
@ -46,6 +60,51 @@ class Path(str):
raise FileExistsError('File or directory already exists:', target)
@cached_property
def isdir(self):
return os.path.isdir(self)
@cached_property
def isfile(self):
return os.path.isfile(self)
@cached_property
def islink(self):
return os.path.islink(self)
@property
def mtime(self):
return os.path.getmtime(self)
@cached_property
def name(self):
return os.path.basename(self)
@cached_property
def parent(self):
return Path(os.path.dirname(self))
@property
def size(self):
return os.path.getsize(self)
@cached_property
def stem(self):
return os.path.basename(self).split('.')[0]
@cached_property
def suffix(self):
return os.path.splitext(self)[1]
def append(self, text):
return Path(self + text)
@ -88,6 +147,10 @@ class Path(str):
return not self.exists
def exists(self):
return os.path.exists(self)
def expanduser(self):
return Path(os.path.expanduser(self))
@ -161,6 +224,10 @@ class Path(str):
return fd.readlines()
def resolve(self):
return Path(os.path.abspath(self))
def touch(self, mode=0o644, utime=None):
timestamp = utime or datetime.now().timestamp()
@ -174,63 +241,3 @@ class Path(str):
def write(self, data, mode='w'):
with self.open(mode) as fd:
fd.write(data)
@property
def exists(self):
return os.path.exists(self)
@cached_property
def home(self):
return Path('~')
@cached_property
def isdir(self):
return os.path.isdir(self)
@cached_property
def isfile(self):
return os.path.isfile(self)
@cached_property
def islink(self):
return os.path.islink(self)
@property
def mtime(self):
return os.path.getmtime(self)
@cached_property
def name(self):
return os.path.basename(self)
@cached_property
def parent(self):
return Path(os.path.dirname(self))
@cached_property
def resolve(self):
return Path(os.path.abspath(self))
@property
def size(self):
return os.path.getsize(self)
@cached_property
def stem(self):
return os.path.basename(self).split('.')[0]
@cached_property
def suffix(self):
return os.path.splitext(self)[1]

View file

@ -46,6 +46,8 @@ hasher =
http_server =
sanic == 21.6.2
envbash == 1.2.0
http_server_async =
http_router == 2.6.4
http_signatures =
pycryptodome == 3.10.1
tldextract == 3.1.2