From 4d61bc9b283378e5b0a40faeef217df5a835a08a Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Thu, 25 Feb 2021 13:04:49 +0100 Subject: [PATCH] v1.0.0 :hatching_chick: --- CHANGELOG.md | 5 ++ MANIFEST.in | 1 + blacksheep/baseapp.pyx | 30 ++------- blacksheep/server/application.py | 19 ++---- blacksheep/server/asgi.py | 15 +++++ blacksheep/server/bindings.py | 7 +- blacksheep/server/errors.py | 48 ++++++++++++++ blacksheep/server/res/error.css | 89 +++++++++++++++++++++++++ blacksheep/server/res/error.html | 47 +++++++++----- itests/test_server.py | 107 ------------------------------- requirements.txt | 8 +-- setup.py | 10 +-- tests/test_application.py | 15 +++-- tests/test_requests.py | 44 +++++++++++-- 14 files changed, 264 insertions(+), 181 deletions(-) create mode 100644 blacksheep/server/asgi.py create mode 100644 blacksheep/server/errors.py create mode 100644 blacksheep/server/res/error.css diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ae0684..41f9cc06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0] - 2021-02-25 :hatching_chick: +- Upgrades dependencies +- Improves the internal server error page and the code handling it +- Marks the web framework as stable + ## [0.3.2] - 2021-01-24 :grapes: - Logs handled and unhandled exceptions (fixes: #75) - Adds support for [Flask Variable Rules syntax](https://flask.palletsprojects.com/en/1.1.x/quickstart/?highlight=routing#variable-rules) (ref. #76) and more granular control on the diff --git a/MANIFEST.in b/MANIFEST.in index 87649b1e..7e2451d1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ include LICENSE include README.md include blacksheep/server/res/*.html +include blacksheep/server/res/*.css include build_info.txt recursive-include blacksheep *.pyx *.pxd *.pxi *.pyi *.py *.c *.h diff --git a/blacksheep/baseapp.pyx b/blacksheep/baseapp.pyx index 92fbd5c7..b9e0c5f6 100644 --- a/blacksheep/baseapp.pyx +++ b/blacksheep/baseapp.pyx @@ -1,6 +1,6 @@ from .messages cimport Request, Response -from .contents cimport TextContent, HtmlContent -from .exceptions cimport HTTPException, NotFound +from .contents cimport TextContent +from .exceptions cimport HTTPException import os @@ -94,30 +94,8 @@ cdef class BaseApplication: async def handle_internal_server_error(self, Request request, Exception exc): if self.show_error_details: - tb = traceback.format_exception( - exc.__class__, - exc, - exc.__traceback__ - ) - info = '' - for item in tb: - info += f'
  • {html.escape(item)}
  • ' - - content = HtmlContent( - self.resources.error_page_html.format_map( - { - 'process_id': os.getpid(), - 'info': info, - 'exctype': exc.__class__.__name__, - 'excmessage': str(exc), - 'method': request.method, - 'path': request.url.value.decode() - } - ) - ) - - return Response(500, content=content) - return Response(500, content=TextContent('Internal server error.')) + return self.server_error_details_handler.produce_response(request, exc) + return Response(500, content=TextContent("Internal server error.")) async def _apply_exception_handler(self, Request request, Exception exc, object exception_handler): try: diff --git a/blacksheep/server/application.py b/blacksheep/server/application.py index 86cdeaab..dd3fe641 100644 --- a/blacksheep/server/application.py +++ b/blacksheep/server/application.py @@ -1,5 +1,6 @@ -from blacksheep.server.files import ServeFilesOptions +from blacksheep.server.errors import ServerErrorDetailsHandler import logging +import os from typing import ( Any, Awaitable, @@ -33,9 +34,9 @@ from blacksheep.server.bindings import ControllerParameter from blacksheep.server.controllers import router as controllers_router from blacksheep.server.cors import CORSPolicy, CORSStrategy, get_cors_middleware +from blacksheep.server.files import ServeFilesOptions from blacksheep.server.files.dynamic import serve_files_dynamic from blacksheep.server.normalization import normalize_handler, normalize_middleware -from blacksheep.server.resources import get_resource_file_content from blacksheep.server.routing import RegisteredRoute, Router, RoutesRegistry from blacksheep.utils import ensure_bytes, join_fragments from guardpost.asynchronous.authentication import AuthenticationStrategy @@ -63,11 +64,6 @@ async def default_headers_middleware( return default_headers_middleware -class Resources: - def __init__(self, error_page_html: str): - self.error_page_html = error_page_html - - class ApplicationEvent: def __init__(self, context: Any) -> None: self.__handlers: List[Callable[..., Any]] = [] @@ -115,26 +111,24 @@ def __init__( self, *, router: Optional[Router] = None, - resources: Optional[Resources] = None, services: Optional[Container] = None, debug: bool = False, - show_error_details: bool = False, + show_error_details: Optional[bool] = None, ): if router is None: router = Router() if services is None: services = Container() + if show_error_details is None: + show_error_details = bool(os.environ.get("APP_SHOW_ERROR_DETAILS", False)) super().__init__(show_error_details, router) - if resources is None: - resources = Resources(get_resource_file_content("error.html")) self.services: Container = services self._service_provider: Optional[Services] = None self.debug = debug self.middlewares: List[Callable[..., Awaitable[Response]]] = [] self._default_headers: Optional[Tuple[Tuple[str, str], ...]] = None self._middlewares_configured = False - self.resources = resources self._cors_strategy: Optional[CORSStrategy] = None self._authentication_strategy: Optional[AuthenticationStrategy] = None self._authorization_strategy: Optional[AuthorizationStrategy] = None @@ -144,6 +138,7 @@ def __init__( self.started = False self.controllers_router: RoutesRegistry = controllers_router self.files_handler = FilesHandler() + self.server_error_details_handler = ServerErrorDetailsHandler() @property def service_provider(self) -> Services: diff --git a/blacksheep/server/asgi.py b/blacksheep/server/asgi.py new file mode 100644 index 00000000..f1e18e52 --- /dev/null +++ b/blacksheep/server/asgi.py @@ -0,0 +1,15 @@ +from blacksheep.messages import Request + + +def get_request_url(request: Request) -> str: + protocol = request.scope.get("type") + host, port = request.scope.get("server") + + if protocol == "http" and port == 80: + port_part = "" + elif protocol == "https" and port == 443: + port_part = "" + else: + port_part = f":{port}" + + return f"{protocol}://{host}{port_part}{request.url.value.decode()}" diff --git a/blacksheep/server/bindings.py b/blacksheep/server/bindings.py index e10f4e0b..2cbe90eb 100644 --- a/blacksheep/server/bindings.py +++ b/blacksheep/server/bindings.py @@ -35,7 +35,7 @@ from blacksheep.url import URL from dateutil.parser import parse as dateutil_parser from guardpost.authentication import Identity -from rodi import Services +from rodi import Services, CannotResolveTypeException T = TypeVar("T") TypeOrName = Union[Type, str] @@ -751,7 +751,10 @@ async def get_value(self, request: Request) -> Optional[T]: # (across parameters and middlewares) context = None - return self.services.get(self.expected_type, context) + try: + return self.services.get(self.expected_type, context) + except CannotResolveTypeException: + return None class ControllerParameter(BoundValue[T]): diff --git a/blacksheep/server/errors.py b/blacksheep/server/errors.py new file mode 100644 index 00000000..55d8ba41 --- /dev/null +++ b/blacksheep/server/errors.py @@ -0,0 +1,48 @@ +import html +import traceback + +from blacksheep.contents import HtmlContent +from blacksheep.messages import Request, Response +from blacksheep.server.asgi import get_request_url +from blacksheep.server.resources import get_resource_file_content + + +def _load_error_page_template() -> str: + error_css = get_resource_file_content("error.css") + error_template = get_resource_file_content("error.html") + assert "/*STYLES*/" in error_template + + # since later it is used in format_map... + error_css = error_css.replace("{", "{{").replace("}", "}}") + return error_template.replace("/*STYLES*/", error_css) + + +class ServerErrorDetailsHandler: + """ + This class is responsible of producing a detailed response when the Application is + configured to show error details to the client, and an unhandled exception happens. + """ + + def __init__(self) -> None: + self._error_page_template = _load_error_page_template() + + def produce_response(self, request: Request, exc: Exception) -> Response: + tb = traceback.format_exception(exc.__class__, exc, exc.__traceback__) + info = "" + for item in tb: + info += f"
  • {html.escape(item)}
  • " + + content = HtmlContent( + self._error_page_template.format_map( + { + "info": info, + "exctype": exc.__class__.__name__, + "excmessage": str(exc), + "method": request.method, + "path": request.url.value.decode(), + "full_url": get_request_url(request), + } + ) + ) + + return Response(500, content=content) diff --git a/blacksheep/server/res/error.css b/blacksheep/server/res/error.css new file mode 100644 index 00000000..dd4316ba --- /dev/null +++ b/blacksheep/server/res/error.css @@ -0,0 +1,89 @@ +body { + font-family: sans-serif; + margin: 0; + padding: 0; +} + +dl { + display: flex; + flex-flow: row; + flex-wrap: wrap; + overflow: visible; +} + +dt { + font-weight: bold; + flex: 0 0 20%; + text-overflow: ellipsis; + overflow: hidden; + border-bottom: 1px dotted #eee; + padding: 10px 0; +} + +dd { + flex: 0 0 80%; + margin-left: auto; + text-align: left; + text-overflow: ellipsis; + overflow: hidden; + padding: 10px 0; + border-bottom: 1px dotted #eee; +} + +dt, dd { + padding: .3rem 0; +} + +header { + background-color: #1c6962; + color: white; + padding: .5rem 2rem; + border-bottom: 1px solid #0f5046; +} + +#content { + padding: .5rem 2rem; +} + +.stack-trace { + font-family: monospace; + background: aliceblue; + padding: 1rem 3.5rem; + border: 1px dashed #366a62; + font-size: 14px; +} + +.stack-trace pre { + padding-left: 60px; + word-break: break-word; + white-space: break-spaces; +} + +.custom-counter { + margin: 0; + padding: 0 1rem 0 0; + list-style-type: none; +} + +.custom-counter li { + counter-increment: step-counter; + margin-bottom: 5px; +} + +.custom-counter li::before { + content: counter(step-counter); + margin-right: 20px; + font-size: 80%; + background-color: rgb(54 106 98); + color: white; + font-weight: bold; + padding: 3px 8px; + float: left; + border-radius: 11px; + margin-left: 20px; +} + +.notes { + padding: 1rem 0; + font-size: small; +} diff --git a/blacksheep/server/res/error.html b/blacksheep/server/res/error.html index 2fa1ae8a..bff771a1 100644 --- a/blacksheep/server/res/error.html +++ b/blacksheep/server/res/error.html @@ -5,24 +5,37 @@ -

    Internal Server Error.

    -

    While handling request: {method} {path}

    -
    -
    Exception type:
    -
    {exctype}
    -
    Exception message:
    -
    {excmessage}
    -
    -
    -

    Stack trace:

    -
      {info}
    -
    -

    Process ID: {process_id}.

    +
    +

    Internal Server Error.

    +

    While handling request: {method} {path}

    +
    +
    +
    +
    +
    Exception type:
    +
    {exctype}
    +
    Exception message:
    +
    {excmessage}
    +
    Method:
    +
    {method}
    +
    URL:
    +
    {full_url}
    +
    +
    +
    +

    Stack trace:

    +
      {info}
    +
    +
    + + This error is displayed for diagnostic purpose. Error details + should be hidden during normal service operation. + +
    +
    - \ No newline at end of file + diff --git a/itests/test_server.py b/itests/test_server.py index 11a82b36..3afb3ef8 100644 --- a/itests/test_server.py +++ b/itests/test_server.py @@ -9,7 +9,6 @@ import yaml from .client_fixtures import get_static_path -from .lorem import LOREM_IPSUM from .server_fixtures import * # NoQA from .utils import assert_files_equals, ensure_success @@ -219,112 +218,6 @@ def test_exception_handling_with_response(session_two): assert response.text == "Fake exception, to test handlers" -if os.environ.get("SLOW_TESTS") == "1": - - def test_low_level_hello_world(socket_connection, server_host): - - lines = b"\r\n".join( - [ - b"GET /hello-world HTTP/1.1", - b"Host: " + server_host.encode(), - b"\r\n\r\n", - ] - ) - - socket_connection.send(lines) - - response_bytes = bytearray() - - while True: - chunk = socket_connection.recv(1024) - - if chunk: - response_bytes.extend(chunk) - else: - break - - value = bytes(response_bytes) - assert b"content-length: 13" in value - assert value.endswith(b"Hello, World!") - - @pytest.mark.parametrize("value", [LOREM_IPSUM]) - def test_posting_chunked_text(socket_connection, server_host, value): - - lines = b"\r\n".join( - [ - b"POST /echo-chunked-text HTTP/1.1", - b"Host: " + server_host.encode(), - b"Transfer-Encoding: chunked", - b"Content-Type: text/plain; charset=utf-8", - ] - ) - - socket_connection.send(lines) - - for line in value.splitlines(): - chunk = line.encode("utf-8") - socket_connection.send( - (hex(len(chunk))).encode()[2:] + b"\r\n" + chunk + b"\r\n" - ) - - socket_connection.send(b"0\r\n\r\n") - - response_bytes = bytearray() - - while True: - chunk = socket_connection.recv(1024) - - if chunk: - response_bytes.extend(chunk) - else: - break - - response_bytes = bytes(response_bytes) - value = value.replace("\n", "") # NB: splitlines removes line breaks - - content_length_header = b"content-length: " + str(len(value)).encode() - - assert content_length_header in response_bytes - assert response_bytes.endswith(value.encode("utf-8")) - - @pytest.mark.parametrize("value", [LOREM_IPSUM]) - def test_posting_chunked_text_streamed(socket_connection, server_host, value): - - lines = b"\r\n".join( - [ - b"POST /echo-streamed-text HTTP/1.1", - b"Host: " + server_host.encode(), - b"Transfer-Encoding: chunked", - b"Content-Type: text/plain; charset=utf-8", - ] - ) - - socket_connection.send(lines) - - for line in value.splitlines(): - chunk = line.encode("utf-8") - socket_connection.send( - (hex(len(chunk))).encode()[2:] + b"\r\n" + chunk + b"\r\n" - ) - - socket_connection.send(b"0\r\n\r\n") - - response_bytes = bytearray() - - while True: - chunk = socket_connection.recv(1024) - - if chunk: - response_bytes.extend(chunk) - else: - break - - response_bytes = bytes(response_bytes) - # value = value.replace('\n', '') # NB: splitlines removes line breaks - - assert b"transfer-encoding: chunked" in response_bytes - - @pytest.mark.parametrize( "url_path,file_name", [("/pexels-photo-923360.jpeg", "example.jpg"), ("/example.html", "example.html")], diff --git a/requirements.txt b/requirements.txt index 772e4bb6..d5a1042a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,13 +6,13 @@ cchardet==2.1.5 certifi>=2020.12.5 chardet==3.0.4 click==7.1.2 -Cython==0.29.14; platform_system != "Windows" +Cython==0.29.22; platform_system != "Windows" decorator==4.4.1 entrypoints==0.3 essentials==1.1.3 flake8==3.7.9 Flask==1.1.1 -guardpost==0.0.5 +guardpost==0.0.7 h11==0.11.0 httptools==0.1.1 idna==2.8 @@ -37,7 +37,7 @@ pytest==5.4.1 pytest-asyncio==0.14.0 regex==2020.4.4 requests==2.22.0 -rodi==1.0.8 +rodi==1.1.1 six==1.13.0 toml==0.10.1 typed-ast==1.4.1 @@ -51,5 +51,5 @@ zipp==0.6.0 pydantic==1.6.1 essentials-openapi==0.0.2 python-dateutil==2.8.1 -uvloop==0.14.0; platform_system != "Windows" +uvloop==0.15.2; platform_system != "Windows" pytest-cov diff --git a/setup.py b/setup.py index a0b49eb7..6565e504 100644 --- a/setup.py +++ b/setup.py @@ -11,12 +11,12 @@ def readme(): setup( name="blacksheep", - version="0.3.2", + version="1.0.0", description="Fast web framework and HTTP client for Python asyncio", long_description=readme(), long_description_content_type="text/markdown", classifiers=[ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", @@ -26,7 +26,7 @@ def readme(): "Framework :: AsyncIO", ], setup_requires=["wheel"], - url="https://github.com/RobertoPrevato/BlackSheep", + url="https://github.com/Neoteroi/BlackSheep", author="Roberto Prevato", author_email="roberto.prevato@gmail.com", keywords="BlackSheep web framework", @@ -86,8 +86,8 @@ def readme(): "Jinja2==2.11.2", "certifi>=2020.12.5", "cchardet~=2.1.5", - "guardpost~=0.0.5", - "rodi~=1.0.8", + "guardpost~=0.0.7", + "rodi~=1.1.1", "essentials==1.1.4", "essentials-openapi==0.1.1", "typing_extensions; python_version < '3.8'", diff --git a/tests/test_application.py b/tests/test_application.py index d075626d..63d15036 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -83,18 +83,25 @@ async def test_application_service_provider_throws_for_missing_value(): def get_example_scope( - method: str, path: str, extra_headers=None, query: Optional[bytes] = b"" + method: str, + path: str, + extra_headers=None, + query: Optional[bytes] = b"", + scheme: str = "http", + server: Optional[List] = None, ): if "?" in path: raise ValueError("The path in ASGI messages does not contain query string") if query.startswith(b""): query = query.lstrip(b"") + if server is None: + server = ["127.0.0.1", 8000] return { - "type": "http", + "type": scheme, "http_version": "1.1", - "server": ["127.0.0.1", 8000], + "server": server, "client": ["127.0.0.1", 51492], - "scheme": "http", + "scheme": scheme, "method": method, "root_path": "", "path": path, diff --git a/tests/test_requests.py b/tests/test_requests.py index ea949086..35d5a94c 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1,12 +1,15 @@ import pytest -from blacksheep import scribe -from blacksheep import Request, Content -from blacksheep.scribe import write_small_request +from blacksheep import Content, Request, scribe +from blacksheep.contents import FormPart, MultiPartFormData from blacksheep.exceptions import BadRequestFormat -from blacksheep.contents import MultiPartFormData, FormPart +from blacksheep.scribe import write_small_request +from blacksheep.server.asgi import get_request_url from blacksheep.url import URL +from .test_application import get_example_scope + + def test_request_supports_dynamic_attributes(): request = Request("GET", b"/", None) foo = object() @@ -250,3 +253,36 @@ def test_request_content_type_is_read_from_content(): ) assert request.content_type() == request.content.type + + +@pytest.mark.parametrize( + "scope,expected_value", + [ + ( + get_example_scope("GET", "/foo", scheme="http", server=["127.0.0.1", 8000]), + "http://127.0.0.1:8000/foo", + ), + ( + get_example_scope("GET", "/foo", scheme="http", server=["127.0.0.1", 80]), + "http://127.0.0.1/foo", + ), + ( + get_example_scope( + "GET", "/foo", scheme="https", server=["127.0.0.1", 44777] + ), + "https://127.0.0.1:44777/foo", + ), + ( + get_example_scope("GET", "/foo", scheme="https", server=["127.0.0.1", 443]), + "https://127.0.0.1/foo", + ), + ], +) +def test_get_asgi_request_full_url(scope, expected_value): + request = Request.incoming( + scope["method"], scope["raw_path"], scope["query_string"], scope["headers"] + ) + request.scope = scope + + full_url = get_request_url(request) + assert full_url == expected_value