diff --git a/.codeclimate.yml b/.codeclimate.yml index 506005ac..08c10e26 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -10,3 +10,15 @@ exclude_patterns: - "examples/" - "hack/" - "scripts/" + - "tests/" +checks: + argument-count: + enabled: false + file-lines: + config: + threshold: 1000 + method-count: + config: + threshold: 40 + complex-logic: + enabled: false diff --git a/README.rst b/README.rst index c3623bb8..c6616f16 100644 --- a/README.rst +++ b/README.rst @@ -77,17 +77,7 @@ The goal of the project is to provide a simple way to get up and running a highl Sponsor ------- -|Try CodeStream| - -.. |Try CodeStream| image:: https://alt-images.codestream.com/codestream_logo_sanicorg.png - :target: https://codestream.com/?utm_source=github&utm_campaign=sanicorg&utm_medium=banner - :alt: Try CodeStream - -Manage pull requests and conduct code reviews in your IDE with full source-tree context. Comment on any line, not just the diffs. Use jump-to-definition, your favorite keybindings, and code intelligence with more of your workflow. - -`Learn More `_ - -Thank you to our sponsor. Check out `open collective `_ to learn more about helping to fund Sanic. +Check out `open collective `_ to learn more about helping to fund Sanic. Installation ------------ diff --git a/sanic/app.py b/sanic/app.py index 07c5a9e8..ac346d73 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -21,6 +21,7 @@ from traceback import format_exc from types import SimpleNamespace from typing import ( Any, + AnyStr, Awaitable, Callable, Coroutine, @@ -138,7 +139,7 @@ class Sanic(BaseSanic): log_config: Optional[Dict[str, Any]] = None, configure_logging: bool = True, register: Optional[bool] = None, - dumps: Optional[Callable[..., str]] = None, + dumps: Optional[Callable[..., AnyStr]] = None, ) -> None: super().__init__(name=name) @@ -193,7 +194,7 @@ class Sanic(BaseSanic): self.router.ctx.app = self if dumps: - BaseHTTPResponse._dumps = dumps + BaseHTTPResponse._dumps = dumps # type: ignore @property def loop(self): @@ -737,15 +738,14 @@ class Sanic(BaseSanic): request.route = route if ( - request.stream.request_body # type: ignore + request.stream + and request.stream.request_body and not route.ctx.ignore_body ): if hasattr(handler, "is_stream"): # Streaming handler: lift the size limit - request.stream.request_max_size = float( # type: ignore - "inf" - ) + request.stream.request_max_size = float("inf") else: # Non-streaming handler: preload body await request.receive_body() diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index 5af1610d..3c2f4430 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -781,6 +781,7 @@ class RouteMixin: path={file_or_directory}, " f"relative_url={__file_uri__}" ) + raise def _register_static( self, diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 00000000..653857a1 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,137 @@ +import asyncio +import json as stdjson + +from collections import namedtuple +from textwrap import dedent +from typing import AnyStr + +import pytest + +from sanic_testing.reusable import ReusableClient + +from sanic import json, text +from sanic.app import Sanic + + +PORT = 1234 + + +class RawClient: + CRLF = b"\r\n" + + def __init__(self, host: str, port: int): + self.reader = None + self.writer = None + self.host = host + self.port = port + + async def connect(self): + self.reader, self.writer = await asyncio.open_connection( + self.host, self.port + ) + + async def close(self): + self.writer.close() + await self.writer.wait_closed() + + async def send(self, message: AnyStr): + if isinstance(message, str): + msg = self._clean(message).encode("utf-8") + else: + msg = message + await self._send(msg) + + async def _send(self, message: bytes): + if not self.writer: + raise Exception("No open write stream") + self.writer.write(message) + + async def recv(self, nbytes: int = -1) -> bytes: + if not self.reader: + raise Exception("No open read stream") + return await self.reader.read(nbytes) + + def _clean(self, message: str) -> str: + return ( + dedent(message) + .lstrip("\n") + .replace("\n", self.CRLF.decode("utf-8")) + ) + + +@pytest.fixture +def test_app(app: Sanic): + app.config.KEEP_ALIVE_TIMEOUT = 1 + + @app.get("/") + async def base_handler(request): + return text("111122223333444455556666777788889999") + + @app.post("/upload", stream=True) + async def upload_handler(request): + data = [part.decode("utf-8") async for part in request.stream] + return json(data) + + return app + + +@pytest.fixture +def runner(test_app): + client = ReusableClient(test_app, port=PORT) + client.run() + yield client + client.stop() + + +@pytest.fixture +def client(runner): + client = namedtuple("Client", ("raw", "send", "recv")) + + raw = RawClient(runner.host, runner.port) + runner._run(raw.connect()) + + def send(msg): + nonlocal runner + nonlocal raw + runner._run(raw.send(msg)) + + def recv(**kwargs): + nonlocal runner + nonlocal raw + method = raw.recv_until if "until" in kwargs else raw.recv + return runner._run(method(**kwargs)) + + yield client(raw, send, recv) + + runner._run(raw.close()) + + +def test_full_message(client): + client.send( + """ + GET / HTTP/1.1 + host: localhost:7777 + + """ + ) + response = client.recv() + assert len(response) == 140 + assert b"200 OK" in response + + +def test_transfer_chunked(client): + client.send( + """ + POST /upload HTTP/1.1 + transfer-encoding: chunked + + """ + ) + client.send(b"3\r\nfoo\r\n") + client.send(b"3\r\nbar\r\n") + client.send(b"0\r\n\r\n") + response = client.recv() + _, body = response.rsplit(b"\r\n\r\n", 1) + data = stdjson.loads(body) + + assert data == ["foo", "bar"] diff --git a/tests/test_static.py b/tests/test_static.py index 00e5611d..96929d01 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -461,6 +461,22 @@ def test_nested_dir(app, static_file_directory): assert response.text == "foo\n" +def test_handle_is_a_directory_error(app, static_file_directory): + error_text = "Is a directory. Access denied" + app.static("/static", static_file_directory) + + @app.exception(Exception) + async def handleStaticDirError(request, exception): + if isinstance(exception, IsADirectoryError): + return text(error_text, status=403) + raise exception + + request, response = app.test_client.get("/static/") + + assert response.status == 403 + assert response.text == error_text + + def test_stack_trace_on_not_found(app, static_file_directory, caplog): app.static("/static", static_file_directory)