diff --git a/.codeclimate.yml b/.codeclimate.yml deleted file mode 100644 index 13a5783d..00000000 --- a/.codeclimate.yml +++ /dev/null @@ -1,28 +0,0 @@ -exclude_patterns: - - "sanic/__main__.py" - - "sanic/application/logo.py" - - "sanic/application/motd.py" - - "sanic/reloader_helpers.py" - - "sanic/simple.py" - - "sanic/utils.py" - - ".github/" - - "changelogs/" - - "docker/" - - "docs/" - - "examples/" - - "scripts/" - - "tests/" -checks: - argument-count: - enabled: false - file-lines: - config: - threshold: 1000 - method-count: - config: - threshold: 40 - complex-logic: - enabled: false - method-complexity: - config: - threshold: 10 diff --git a/.coveragerc b/.coveragerc index 63bec82c..22856065 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,13 +3,12 @@ branch = True source = sanic omit = site-packages - sanic/application/logo.py - sanic/application/motd.py - sanic/cli sanic/__main__.py + sanic/compat.py sanic/reloader_helpers.py sanic/simple.py sanic/utils.py + sanic/cli [html] directory = coverage @@ -21,3 +20,12 @@ exclude_lines = noqa NOQA pragma: no cover +omit = + site-packages + sanic/__main__.py + sanic/compat.py + sanic/reloader_helpers.py + sanic/simple.py + sanic/utils.py + sanic/cli +skip_empty = True diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 9b5834fa..419ab0ca 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -20,7 +20,6 @@ jobs: steps: - uses: actions/checkout@v2 - - uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} @@ -29,9 +28,9 @@ jobs: run: | python -m pip install --upgrade pip pip install tox - - uses: paambaati/codeclimate-action@v2.5.3 - if: always() - env: - CC_TEST_REPORTER_ID: ${{ secrets.CODECLIMATE }} + - name: Run coverage + run: tox -e coverage + - uses: codecov/codecov-action@v2 with: - coverageCommand: tox -e coverage + files: ./coverage.xml + fail_ci_if_error: false diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..5905afd5 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,27 @@ +coverage: + status: + patch: + default: + target: auto + threshold: 0.75 + project: + default: + target: auto + threshold: 0.5 + precision: 3 +codecov: + require_ci_to_pass: false +ignore: + - "sanic/__main__.py" + - "sanic/compat.py" + - "sanic/reloader_helpers.py" + - "sanic/simple.py" + - "sanic/utils.py" + - "sanic/cli" + - ".github/" + - "changelogs/" + - "docker/" + - "docs/" + - "examples/" + - "scripts/" + - "tests/" diff --git a/sanic/app.py b/sanic/app.py index 97c7a177..ae19fc3f 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -114,7 +114,7 @@ if TYPE_CHECKING: # no cov Extend = TypeVar("Extend") # type: ignore -if OS_IS_WINDOWS: +if OS_IS_WINDOWS: # no cov enable_windows_color_support() filterwarnings("once", category=DeprecationWarning) @@ -1554,7 +1554,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): ) -> Task: if not isinstance(task, Future): prepped = cls._prep_task(task, app, loop) - if sys.version_info < (3, 8): + if sys.version_info < (3, 8): # no cov if name: error_logger.warning( "Cannot set a name for a task when using Python 3.7. " @@ -1598,7 +1598,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): :param task: future, couroutine or awaitable """ - if name and sys.version_info == (3, 7): + if name and sys.version_info < (3, 8): # no cov name = None error_logger.warning( "Cannot set a name for a task when using Python 3.7. Your " @@ -1626,7 +1626,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): def get_task( self, name: str, *, raise_exception: bool = True ) -> Optional[Task]: - if sys.version_info < (3, 8): + if sys.version_info < (3, 8): # no cov error_logger.warning( "This feature (get_task) is only supported on using " "Python 3.8+." @@ -1648,7 +1648,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): *, raise_exception: bool = True, ) -> None: - if sys.version_info < (3, 8): + if sys.version_info < (3, 8): # no cov error_logger.warning( "This feature (cancel_task) is only supported on using " "Python 3.8+." @@ -1660,7 +1660,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): if msg: if sys.version_info >= (3, 9): args = (msg,) - else: + else: # no cov raise RuntimeError( "Cancelling a task with a message is only supported " "on Python 3.9+." @@ -1672,7 +1672,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): ... def purge_tasks(self): - if sys.version_info < (3, 8): + if sys.version_info < (3, 8): # no cov error_logger.warning( "This feature (purge_tasks) is only supported on using " "Python 3.8+." @@ -1709,7 +1709,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): @property def tasks(self): - if sys.version_info < (3, 8): + if sys.version_info < (3, 8): # no cov error_logger.warning( "This feature (tasks) is only supported on using " "Python 3.8+." diff --git a/sanic/handlers.py b/sanic/handlers.py index 44ff77a7..6ad371e8 100644 --- a/sanic/handlers.py +++ b/sanic/handlers.py @@ -53,14 +53,14 @@ class ErrorHandler: self._warn_fallback_deprecation() @property - def fallback(self): + def fallback(self): # no cov # This is for backwards compat and can be removed in v22.6 if self._fallback is _default: return DEFAULT_FORMAT return self._fallback @fallback.setter - def fallback(self, value: str): + def fallback(self, value: str): # no cov self._warn_fallback_deprecation() if not isinstance(value, str): raise SanicException( @@ -236,7 +236,7 @@ class ErrorHandler: except Exception: try: url = repr(request.url) - except AttributeError: + except AttributeError: # no cov url = "unknown" response_message = ( "Exception raised in exception handler " '"%s" for uri: %s' @@ -281,7 +281,7 @@ class ErrorHandler: if quiet is False or noisy is True: try: url = repr(request.url) - except AttributeError: + except AttributeError: # no cov url = "unknown" error_logger.exception( diff --git a/sanic/log.py b/sanic/log.py index 000b3ed8..4b3b960c 100644 --- a/sanic/log.py +++ b/sanic/log.py @@ -6,7 +6,7 @@ from typing import Any, Dict from warnings import warn -LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( +LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov version=1, disable_existing_loggers=False, loggers={ @@ -57,7 +57,7 @@ LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( ) -class Colors(str, Enum): +class Colors(str, Enum): # no cov END = "\033[0m" BLUE = "\033[01;34m" GREEN = "\033[01;32m" @@ -65,23 +65,23 @@ class Colors(str, Enum): RED = "\033[01;31m" -logger = logging.getLogger("sanic.root") +logger = logging.getLogger("sanic.root") # no cov """ General Sanic logger """ -error_logger = logging.getLogger("sanic.error") +error_logger = logging.getLogger("sanic.error") # no cov """ Logger used by Sanic for error logging """ -access_logger = logging.getLogger("sanic.access") +access_logger = logging.getLogger("sanic.access") # no cov """ Logger used by Sanic for access logging """ -def deprecation(message: str, version: float): +def deprecation(message: str, version: float): # no cov version_info = f"[DEPRECATION v{version}] " if sys.stdout.isatty(): version_info = f"{Colors.RED}{version_info}" diff --git a/sanic/models/asgi.py b/sanic/models/asgi.py index 57b755ee..74e0e8b2 100644 --- a/sanic/models/asgi.py +++ b/sanic/models/asgi.py @@ -13,7 +13,7 @@ ASGISend = Callable[[ASGIMessage], Awaitable[None]] ASGIReceive = Callable[[], Awaitable[ASGIMessage]] -class MockProtocol: +class MockProtocol: # no cov def __init__(self, transport: "MockTransport", loop): # This should be refactored when < 3.8 support is dropped self.transport = transport @@ -56,7 +56,7 @@ class MockProtocol: await self._not_paused.wait() -class MockTransport: +class MockTransport: # no cov _protocol: Optional[MockProtocol] def __init__( diff --git a/sanic/server/websockets/frame.py b/sanic/server/websockets/frame.py index fef27db1..e4972516 100644 --- a/sanic/server/websockets/frame.py +++ b/sanic/server/websockets/frame.py @@ -9,7 +9,7 @@ from websockets.typing import Data from sanic.exceptions import ServerError -if TYPE_CHECKING: +if TYPE_CHECKING: # no cov from .impl import WebsocketImplProtocol UTF8Decoder = codecs.getincrementaldecoder("utf-8") @@ -37,7 +37,7 @@ class WebsocketFrameAssembler: "get_id", "put_id", ) - if TYPE_CHECKING: + if TYPE_CHECKING: # no cov protocol: "WebsocketImplProtocol" read_mutex: asyncio.Lock write_mutex: asyncio.Lock @@ -131,7 +131,7 @@ class WebsocketFrameAssembler: if self.paused: self.protocol.resume_frames() self.paused = False - if not self.get_in_progress: + if not self.get_in_progress: # no cov # This should be guarded against with the read_mutex, # exception is here as a failsafe raise ServerError( @@ -204,7 +204,7 @@ class WebsocketFrameAssembler: if self.paused: self.protocol.resume_frames() self.paused = False - if not self.get_in_progress: + if not self.get_in_progress: # no cov # This should be guarded against with the read_mutex, # exception is here as a failsafe raise ServerError( @@ -212,7 +212,7 @@ class WebsocketFrameAssembler: "asynchronous get was in progress." ) self.get_in_progress = False - if not self.message_complete.is_set(): + if not self.message_complete.is_set(): # no cov # This should be guarded against with the read_mutex, # exception is here as a failsafe raise ServerError( @@ -220,7 +220,7 @@ class WebsocketFrameAssembler: "message was complete." ) self.message_complete.clear() - if self.message_fetched.is_set(): + if self.message_fetched.is_set(): # no cov # This should be guarded against with the read_mutex, # and get_in_progress check, this exception is # here as a failsafe diff --git a/tests/conftest.py b/tests/conftest.py index 09194e6e..22decde5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,4 @@ import asyncio -import base64 import logging import random import re @@ -205,7 +204,3 @@ def sanic_ext(ext_instance): # noqa yield sanic_ext with suppress(KeyError): del sys.modules["sanic_ext"] - - -def encode_basic_auth_credentials(username, password): - return base64.b64encode(f"{username}:{password}".encode()).decode("ascii") diff --git a/tests/test_requests.py b/tests/test_requests.py index 0b15d8d6..d752f045 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1,3 +1,4 @@ +import base64 import logging from json import dumps as json_dumps @@ -18,7 +19,10 @@ from sanic import Blueprint, Sanic from sanic.exceptions import ServerError from sanic.request import DEFAULT_HTTP_CONTENT_TYPE, RequestParameters from sanic.response import html, json, text -from tests.conftest import encode_basic_auth_credentials + + +def encode_basic_auth_credentials(username, password): + return base64.b64encode(f"{username}:{password}".encode()).decode("ascii") # ------------------------------------------------------------ # diff --git a/tests/test_websockets.py b/tests/test_websockets.py new file mode 100644 index 00000000..d129533e --- /dev/null +++ b/tests/test_websockets.py @@ -0,0 +1,237 @@ +import re + +from asyncio import Event, Queue, TimeoutError +from unittest.mock import AsyncMock, Mock, call + +import pytest + +from websockets.frames import CTRL_OPCODES, DATA_OPCODES, Frame + +from sanic.exceptions import ServerError +from sanic.server.websockets.frame import WebsocketFrameAssembler + + +@pytest.mark.asyncio +async def test_ws_frame_get_message_incomplete_timeout_0(): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_complete = AsyncMock(spec=Event) + assembler.message_complete.is_set = Mock(return_value=False) + data = await assembler.get(0) + + assert data is None + assembler.message_complete.is_set.assert_called_once() + + +@pytest.mark.asyncio +async def test_ws_frame_get_message_in_progress(): + assembler = WebsocketFrameAssembler(Mock()) + assembler.get_in_progress = True + + message = re.escape( + "Called get() on Websocket frame assembler " + "while asynchronous get is already in progress." + ) + + with pytest.raises(ServerError, match=message): + await assembler.get() + + +@pytest.mark.asyncio +async def test_ws_frame_get_message_incomplete(): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_complete.wait = AsyncMock(return_value=True) + assembler.message_complete.is_set = Mock(return_value=False) + data = await assembler.get() + + assert data is None + assembler.message_complete.wait.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_ws_frame_get_message(): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_complete.wait = AsyncMock(return_value=True) + assembler.message_complete.is_set = Mock(return_value=True) + data = await assembler.get() + + assert data == b"" + assembler.message_complete.wait.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_ws_frame_get_message_with_timeout(): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_complete.wait = AsyncMock(return_value=True) + assembler.message_complete.is_set = Mock(return_value=True) + data = await assembler.get(0.1) + + assert data == b"" + assembler.message_complete.wait.assert_awaited_once() + assert assembler.message_complete.is_set.call_count == 2 + + +@pytest.mark.asyncio +async def test_ws_frame_get_message_with_timeouterror(): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_complete.wait = AsyncMock(return_value=True) + assembler.message_complete.is_set = Mock(return_value=True) + assembler.message_complete.wait.side_effect = TimeoutError("...") + data = await assembler.get(0.1) + + assert data == b"" + assembler.message_complete.wait.assert_awaited_once() + assert assembler.message_complete.is_set.call_count == 2 + + +@pytest.mark.asyncio +async def test_ws_frame_get_not_completed(): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_complete = AsyncMock(spec=Event) + assembler.message_complete.is_set = Mock(return_value=False) + data = await assembler.get() + + assert data is None + + +@pytest.mark.asyncio +async def test_ws_frame_get_not_completed_start(): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_complete = AsyncMock(spec=Event) + assembler.message_complete.is_set = Mock(side_effect=[False, True]) + data = await assembler.get(0.1) + + assert data is None + + +@pytest.mark.asyncio +async def test_ws_frame_get_paused(): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_complete = AsyncMock(spec=Event) + assembler.message_complete.is_set = Mock(side_effect=[False, True]) + assembler.paused = True + data = await assembler.get() + + assert data is None + assembler.protocol.resume_frames.assert_called_once() + + +@pytest.mark.asyncio +async def test_ws_frame_get_data(): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_complete = AsyncMock(spec=Event) + assembler.message_complete.is_set = Mock(return_value=True) + assembler.chunks = [b"foo", b"bar"] + data = await assembler.get() + + assert data == b"foobar" + + +@pytest.mark.asyncio +async def test_ws_frame_get_iter_in_progress(): + assembler = WebsocketFrameAssembler(Mock()) + assembler.get_in_progress = True + + message = re.escape( + "Called get_iter on Websocket frame assembler " + "while asynchronous get is already in progress." + ) + + with pytest.raises(ServerError, match=message): + [x async for x in assembler.get_iter()] + + +@pytest.mark.asyncio +async def test_ws_frame_get_iter_none_in_queue(): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_complete.set() + assembler.chunks = [b"foo", b"bar"] + + chunks = [x async for x in assembler.get_iter()] + + assert chunks == [b"foo", b"bar"] + + +@pytest.mark.asyncio +async def test_ws_frame_get_iter_paused(): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_complete.set() + assembler.paused = True + + [x async for x in assembler.get_iter()] + assembler.protocol.resume_frames.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("opcode", DATA_OPCODES) +async def test_ws_frame_put_not_fetched(opcode): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_fetched.set() + + message = re.escape( + "Websocket put() got a new message when the previous message was " + "not yet fetched." + ) + with pytest.raises(ServerError, match=message): + await assembler.put(Frame(opcode, b"")) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("opcode", DATA_OPCODES) +async def test_ws_frame_put_fetched(opcode): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_fetched = AsyncMock() + assembler.message_fetched.is_set = Mock(return_value=False) + + await assembler.put(Frame(opcode, b"")) + assembler.message_fetched.wait.assert_awaited_once() + assembler.message_fetched.clear.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("opcode", DATA_OPCODES) +async def test_ws_frame_put_message_complete(opcode): + assembler = WebsocketFrameAssembler(Mock()) + assembler.message_complete.set() + + message = re.escape( + "Websocket put() got a new message when a message was " + "already in its chamber." + ) + with pytest.raises(ServerError, match=message): + await assembler.put(Frame(opcode, b"")) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("opcode", DATA_OPCODES) +async def test_ws_frame_put_message_into_queue(opcode): + assembler = WebsocketFrameAssembler(Mock()) + assembler.chunks_queue = AsyncMock(spec=Queue) + assembler.message_fetched = AsyncMock() + assembler.message_fetched.is_set = Mock(return_value=False) + + await assembler.put(Frame(opcode, b"foo")) + + assembler.chunks_queue.put.has_calls( + call(b"foo"), + call(None), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("opcode", DATA_OPCODES) +async def test_ws_frame_put_not_fin(opcode): + assembler = WebsocketFrameAssembler(Mock()) + + retval = await assembler.put(Frame(opcode, b"foo", fin=False)) + + assert retval is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("opcode", CTRL_OPCODES) +async def test_ws_frame_put_skip_ctrl(opcode): + assembler = WebsocketFrameAssembler(Mock()) + + retval = await assembler.put(Frame(opcode, b"")) + + assert retval is None