Skip to content

Commit

Permalink
add files middlewares
Browse files Browse the repository at this point in the history
  • Loading branch information
livioribeiro committed May 13, 2024
1 parent ccb2d65 commit 7924a21
Show file tree
Hide file tree
Showing 11 changed files with 308 additions and 13 deletions.
9 changes: 9 additions & 0 deletions examples/hello_world/configuration/settings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
middleware:
- selva.web.middleware.files.StaticFilesMiddleware
- selva.web.middleware.request_id.RequestIdMiddleware

logging:
root: info
level:
application: info
format: console
1 change: 1 addition & 0 deletions examples/hello_world/resources/static/file.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Lorem ipsum dolor sit amet.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions src/selva/configuration/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,13 @@
"redis": {},
"sqlalchemy": {},
},
"staticfiles": {
"path": "/static",
"root": "resources/static",
"mappings": {},
},
"uploadedfiles": {
"path": "/uploads",
"root": "resources/uploads",
},
}
5 changes: 2 additions & 3 deletions src/selva/ext/templates/jinja/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ async def respond(
template_name: str,
context: dict,
*,
status: HTTPStatus = HTTPStatus.OK,
content_type: str = None,
stream: bool = False,
):
Expand All @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions src/selva/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import sys

import structlog
from uvicorn.config import LOGGING_CONFIG

from selva.configuration.settings import Settings

Expand Down Expand Up @@ -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,
Expand All @@ -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()}
Expand Down
32 changes: 32 additions & 0 deletions src/selva/logging/defaults.py
Original file line number Diff line number Diff line change
@@ -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",
},
}
180 changes: 180 additions & 0 deletions src/selva/logging/logger.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 4 additions & 6 deletions src/selva/web/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
67 changes: 67 additions & 0 deletions src/selva/web/middleware/files.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 0 additions & 1 deletion src/selva/web/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ async def respond(
template_name: str,
context: dict,
*,
status: HTTPStatus = HTTPStatus.OK,
content_type: str = None,
stream: bool = False,
):
Expand Down

0 comments on commit 7924a21

Please sign in to comment.