diff --git a/izzylib/__init__.py b/izzylib/__init__.py index 6893a4b..9090d81 100644 --- a/izzylib/__init__.py +++ b/izzylib/__init__.py @@ -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() diff --git a/izzylib/dotdict.py b/izzylib/dotdict.py index 2d2d404..47ed53e 100644 --- a/izzylib/dotdict.py +++ b/izzylib/dotdict.py @@ -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): diff --git a/izzylib/exceptions.py b/izzylib/exceptions.py index 2ffe72a..febfd8d 100644 --- a/izzylib/exceptions.py +++ b/izzylib/exceptions.py @@ -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 diff --git a/izzylib/http_server/application.py b/izzylib/http_server/application.py index a9bdb97..f7e8beb 100644 --- a/izzylib/http_server/application.py +++ b/izzylib/http_server/application.py @@ -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) diff --git a/izzylib/http_server_async/__init__.py b/izzylib/http_server_async/__init__.py new file mode 100644 index 0000000..35d13d1 --- /dev/null +++ b/izzylib/http_server_async/__init__.py @@ -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 diff --git a/izzylib/http_server_async/application.py b/izzylib/http_server_async/application.py new file mode 100644 index 0000000..e4eb70e --- /dev/null +++ b/izzylib/http_server_async/application.py @@ -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) diff --git a/izzylib/http_server_async/config.py b/izzylib/http_server_async/config.py new file mode 100644 index 0000000..914255d --- /dev/null +++ b/izzylib/http_server_async/config.py @@ -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 diff --git a/izzylib/http_server_async/error.py b/izzylib/http_server_async/error.py new file mode 100644 index 0000000..a00920f --- /dev/null +++ b/izzylib/http_server_async/error.py @@ -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) diff --git a/izzylib/http_server_async/request.py b/izzylib/http_server_async/request.py new file mode 100644 index 0000000..1cadc83 --- /dev/null +++ b/izzylib/http_server_async/request.py @@ -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) diff --git a/izzylib/http_server_async/response.py b/izzylib/http_server_async/response.py new file mode 100644 index 0000000..89cb624 --- /dev/null +++ b/izzylib/http_server_async/response.py @@ -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 diff --git a/izzylib/http_server_async/view.py b/izzylib/http_server_async/view.py new file mode 100644 index 0000000..a1a8610 --- /dev/null +++ b/izzylib/http_server_async/view.py @@ -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 + + diff --git a/izzylib/misc.py b/izzylib/misc.py index d1e2006..7325a6c 100644 --- a/izzylib/misc.py +++ b/izzylib/misc.py @@ -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, diff --git a/izzylib/path.py b/izzylib/path.py index da9d095..eecea36 100644 --- a/izzylib/path.py +++ b/izzylib/path.py @@ -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] diff --git a/setup.cfg b/setup.cfg index 346f72c..49e65fd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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