From 7924a2142e52a82b7ca0b426733fd95124f4a8b8 Mon Sep 17 00:00:00 2001 From: Livio Ribeiro Date: Mon, 13 May 2024 15:16:54 -0300 Subject: [PATCH] add files middlewares --- .../hello_world/configuration/settings.yaml | 9 + .../hello_world/resources/static/file.txt | 1 + pyproject.toml | 2 +- src/selva/configuration/defaults.py | 9 + src/selva/ext/templates/jinja/service.py | 5 +- src/selva/logging.py | 5 +- src/selva/logging/defaults.py | 32 ++++ src/selva/logging/logger.py | 180 ++++++++++++++++++ src/selva/web/application.py | 10 +- src/selva/web/middleware/files.py | 67 +++++++ src/selva/web/templates.py | 1 - 11 files changed, 308 insertions(+), 13 deletions(-) create mode 100644 examples/hello_world/configuration/settings.yaml create mode 100644 examples/hello_world/resources/static/file.txt create mode 100644 src/selva/logging/defaults.py create mode 100644 src/selva/logging/logger.py create mode 100644 src/selva/web/middleware/files.py diff --git a/examples/hello_world/configuration/settings.yaml b/examples/hello_world/configuration/settings.yaml new file mode 100644 index 0000000..8fe53ec --- /dev/null +++ b/examples/hello_world/configuration/settings.yaml @@ -0,0 +1,9 @@ +middleware: +- selva.web.middleware.files.StaticFilesMiddleware +- selva.web.middleware.request_id.RequestIdMiddleware + +logging: + root: info + level: + application: info + format: console diff --git a/examples/hello_world/resources/static/file.txt b/examples/hello_world/resources/static/file.txt new file mode 100644 index 0000000..3ffe139 --- /dev/null +++ b/examples/hello_world/resources/static/file.txt @@ -0,0 +1 @@ +Lorem ipsum dolor sit amet. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a18bbf4..faf7728 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ packages = [ [tool.poetry.dependencies] python = "^3.11" -asgikit = "^0.8" +asgikit = "^0.9" pydantic = "^2.7" python-dotenv = "^1.0" "ruamel.yaml" = "^0.18" diff --git a/src/selva/configuration/defaults.py b/src/selva/configuration/defaults.py index 6115627..055c22d 100644 --- a/src/selva/configuration/defaults.py +++ b/src/selva/configuration/defaults.py @@ -15,4 +15,13 @@ "redis": {}, "sqlalchemy": {}, }, + "staticfiles": { + "path": "/static", + "root": "resources/static", + "mappings": {}, + }, + "uploadedfiles": { + "path": "/uploads", + "root": "resources/uploads", + }, } diff --git a/src/selva/ext/templates/jinja/service.py b/src/selva/ext/templates/jinja/service.py index 2d63881..0a11d84 100644 --- a/src/selva/ext/templates/jinja/service.py +++ b/src/selva/ext/templates/jinja/service.py @@ -38,7 +38,6 @@ async def respond( template_name: str, context: dict, *, - status: HTTPStatus = HTTPStatus.OK, content_type: str = None, stream: bool = False, ): @@ -51,10 +50,10 @@ async def respond( if stream: render_stream = template.generate_async(context) - await respond_stream(response, render_stream, status=status) + await respond_stream(response, render_stream) else: rendered = await template.render_async(context) - await respond_text(response, rendered, status=status) + await respond_text(response, rendered) async def render(self, template_name: str, context: dict) -> str: template = self.environment.get_template(template_name) diff --git a/src/selva/logging.py b/src/selva/logging.py index 092ead3..371f71d 100644 --- a/src/selva/logging.py +++ b/src/selva/logging.py @@ -3,6 +3,7 @@ import sys import structlog +from uvicorn.config import LOGGING_CONFIG from selva.configuration.settings import Settings @@ -45,7 +46,7 @@ def setup(settings: Settings): logging_config = { "version": 1, - "disable_existing_loggers": True, + "disable_existing_loggers": False, "formatters": { "structlog": { "()": structlog.stdlib.ProcessorFormatter, @@ -60,7 +61,7 @@ def setup(settings: Settings): }, "root": { "handlers": ["console"], - "level": settings.logging.get("root", "INFO").upper(), + "level": settings.logging.get("root", "WARN").upper(), }, "loggers": { module: {"level": level.upper()} diff --git a/src/selva/logging/defaults.py b/src/selva/logging/defaults.py new file mode 100644 index 0000000..db755b2 --- /dev/null +++ b/src/selva/logging/defaults.py @@ -0,0 +1,32 @@ +LOGGING = { + "version": 1, + "disable_existing_loggers": True, + "formatters": { + "default": { + "format": "{asctime} {levelname:<8} {message}", + "datefmt": "%Y-%m-%dT%H:%M:%S", + "style": "{", + }, + "logfmt": { + "()": "logfmter.Logfmter", + "keys": ["timestamp", "level", "name", "event"], + "mapping": { + "timestamp": "asctime", + "level": "levelname", + "event": "message", + }, + "datefmt": "%Y-%m-%dT%H:%M:%S", + }, + }, + "handlers": { + "console": { + "level": "INFO", + "class": "logging.StreamHandler", + "formatter": "default", + }, + }, + "root": { + "handlers": ["console"], + "level": "INFO", + }, +} diff --git a/src/selva/logging/logger.py b/src/selva/logging/logger.py new file mode 100644 index 0000000..2ca499c --- /dev/null +++ b/src/selva/logging/logger.py @@ -0,0 +1,180 @@ +import asyncio +import datetime +import functools +import inspect +import json +import logging +import string +import sys +import threading +import traceback +from collections.abc import Callable +from enum import IntEnum +from functools import cached_property +from typing import Any, TypeAlias + +TRACE = 5 + +logging.addLevelName(TRACE, "trace") + +logging.getLevelNamesMapping() + +LogEvent: TypeAlias = str | tuple[str, list] + + +class LogLevel(IntEnum): + NOTSET = logging.NOTSET + TRACE = TRACE + DEBUG = logging.DEBUG + INFO = logging.INFO + WARNING = logging.WARN + ERROR = logging.ERROR + CRITICAL = logging.CRITICAL + + +class LogRecord: + def __init__( + self, + level: LogLevel, + logger_name: str, + event: LogEvent, + context: dict, + exception: BaseException = None, + timestamp: datetime.datetime = None, + ): + self.timestamp = timestamp or datetime.datetime.now() + self.log_level = level + self.log_logger = logger_name + self.log_context = context + self.log_event = event + self.exception = exception + + @cached_property + def event(self): + return self.format_event(self.log_event, self.log_context) + + @staticmethod + def format_event(event: LogEvent, context: dict) -> str: + match event: + case event if isinstance(event, str): + return event.format(**context) + case (event, *args) if isinstance(event, str): + return event.format(*args, **context) + case _: + raise ValueError("Invalid event") + + +def _normalize_name(name: str) -> str: + for char in string.whitespace: + name = name.replace(char, "_").strip() + return name + + +def _normalize_value(value: Any, depth=0): + if depth == 0: + if isinstance(value, bool) and depth == 0: + return "true" if value else "false" + + if isinstance(value, str) and depth == 0: + return json.dumps(value) + + if isinstance(value, (list, tuple, set, frozenset)): + return json.dumps([_normalize_value(v, depth + 1) for v in value]) + + if isinstance(value, dict): + return json.dumps( + {k: _normalize_value(v, depth + 1) for k, v in value.items()} + ) + + if isinstance(value, (str, int, float, bool)): + return value + + return repr(value) + + +def _formatter(record: LogRecord) -> str: + message = json.dumps(record.event) + context = " ".join( + f"{_normalize_name(name)}={_normalize_value(value)}" + for name, value in record.log_context.items() + ) + return f"{record.timestamp.isoformat()} {record.log_level:<8} {message} {context}" + + +class PrintHandler: + def __init__(self, formatter: Callable[[LogRecord], str] = _formatter): + self.formatter = formatter + + def __call__(self, record: LogRecord): + message = self.formatter(record) + print(message, file=sys.stderr) + + +class Logger: + def __init__(self): + self.root_level = LogLevel.INFO + self.level_spec = {} + self.handler = PrintHandler() + + def log(self, level: LogLevel, event: LogEvent, **kwargs): + # if level < self.level: + # return + + name = inspect.currentframe().f_globals.get("__name__") + record = LogRecord(level, name, event, kwargs) + self.queue.put_nowait(record) + # message = _formatter(record) + + # logger = logging.getLogger(name) + # logger.log(level, message) + + # self.handler(record) + + def trace(self, event: LogEvent, **kwargs): + self.log(LogLevel.TRACE, event, **kwargs) + + def debug(self, event: LogEvent, **kwargs): + self.log(LogLevel.DEBUG, event, **kwargs) + + def info(self, event: LogEvent, **kwargs): + self.log(LogLevel.INFO, event, **kwargs) + + def warning(self, event: LogEvent, **kwargs): + self.log(LogLevel.WARNING, event, **kwargs) + + def error(self, event: LogEvent, **kwargs): + self.log(LogLevel.ERROR, event, **kwargs) + + def exception(self, event: LogEvent | BaseException, *, exception=None, **kwargs): + if isinstance(event, BaseException): + exception = event + event = str(event) + + if exception or (exception := sys.exception()): + kwargs["exception"] = "".join(traceback.format_exception(exception)).rstrip( + "\n" + ) + + self.log(LogLevel.ERROR, event, **kwargs) + + def critical(self, event: LogEvent, **kwargs): + self.log(LogLevel.CRITICAL, event, **kwargs) + + # @overload + # def log(self, record: LogRecord, call_level=0): + # if call_level: + # frame = inspect.currentframe() + # while call_level: + # frame = frame.f_back + # call_level -= 1 + # record.name = frame.f_globals.get("__name__") + # + # self.handler(record) + + +if __name__ == "__main__": + logger = Logger() + logger.info("logger") + import time + + time.sleep(2) diff --git a/src/selva/web/application.py b/src/selva/web/application.py index 2507af4..0b86ef7 100644 --- a/src/selva/web/application.py +++ b/src/selva/web/application.py @@ -231,17 +231,15 @@ async def _handle_request(self, scope, receive, send): return if stack_trace: - await respond_text(response, stack_trace, status=err.status) + response.status = err.status + await respond_text(response, stack_trace) else: await respond_status(response, status=err.status) except Exception: logger.exception("error processing request") - await respond_text( - request.response, - traceback.format_exc(), - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) + request.response.status = HTTPStatus.INTERNAL_SERVER_ERROR + await respond_text(request.response, traceback.format_exc()) async def _process_request(self, request: Request): logger.debug( diff --git a/src/selva/web/middleware/files.py b/src/selva/web/middleware/files.py new file mode 100644 index 0000000..4db9dde --- /dev/null +++ b/src/selva/web/middleware/files.py @@ -0,0 +1,67 @@ +from pathlib import Path +from typing import Annotated + +from asgikit.requests import Request +from asgikit.responses import respond_file + +from selva.configuration import Settings +from selva.di import Inject +from selva.web.middleware import CallNext, Middleware +from selva.web.exception import HTTPNotFoundException + + +class StaticFilesMiddleware(Middleware): + settings: Annotated[Settings, Inject] + + def initialize(self): + self.path = self.settings.staticfiles.path + self.root = Path(self.settings.staticfiles.root).resolve() + self.mappings = { + name.lstrip("/"): value.lstrip("/") for name, value in + self.settings.staticfiles.get("mappings").items() + } + + async def __call__( + self, + call_next: CallNext, + request: Request, + ): + file_to_serve = self.mappings.get(request.path.lstrip("/")) + + if not file_to_serve: + if not request.path.startswith(self.path): + await call_next(request) + return + + file_to_serve = request.path.removeprefix(self.path) + + file_to_serve = file_to_serve.lstrip("/") + path_to_serve = (self.root / file_to_serve).resolve() + if not path_to_serve.is_relative_to(self.root) or not path_to_serve.exists() or not path_to_serve.is_file(): + raise HTTPNotFoundException() + + await respond_file(request.response, path_to_serve) + + +class UploadedFilesMiddleware(Middleware): + settings: Annotated[Settings, Inject] + + def initialize(self): + self.path = self.settings.uploadedfiles.path + self.root = Path(self.settings.uploadedfiles.root).resolve() + + async def __call__( + self, + call_next: CallNext, + request: Request, + ): + if not request.path.startswith(self.path): + await call_next(request) + return + + file_to_serve = request.path.removeprefix(self.path).lstrip("/") + path_to_serve = (self.root / file_to_serve).resolve() + if not path_to_serve.is_relative_to(self.root) or not path_to_serve.exists() or not path_to_serve.is_file(): + raise HTTPNotFoundException() + + await respond_file(request.response, path_to_serve) diff --git a/src/selva/web/templates.py b/src/selva/web/templates.py index 0a955b2..01246be 100644 --- a/src/selva/web/templates.py +++ b/src/selva/web/templates.py @@ -12,7 +12,6 @@ async def respond( template_name: str, context: dict, *, - status: HTTPStatus = HTTPStatus.OK, content_type: str = None, stream: bool = False, ):