From ed1f0ecbfba4e262eaaedecbb95c76228e5cbecf Mon Sep 17 00:00:00 2001 From: "bj00rn@users.noreply.github.com" Date: Mon, 4 Dec 2023 16:07:35 +0100 Subject: [PATCH 1/2] chore(ci): implement test server --- setup.cfg | 1 + tests/conftest.py | 28 ++------ tests/test_client.py | 60 +++++----------- tests/{test_utils.py => test_parser.py} | 14 ++-- tests/test_skeleton.py | 4 +- tests/utils/__init__.py | 0 tests/utils/test_server.py | 92 +++++++++++++++++++++++++ 7 files changed, 126 insertions(+), 73 deletions(-) rename tests/{test_utils.py => test_parser.py} (83%) create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_server.py diff --git a/setup.cfg b/setup.cfg index 3b9514d..fbd87e1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -98,6 +98,7 @@ norecursedirs = dist build .tox + tests/utils testpaths = tests # Use pytest markers to select/deselect specific tests # markers = diff --git a/tests/conftest.py b/tests/conftest.py index 0bdaf37..6c95da2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,12 @@ """ - Dummy conftest.py for pysaleryd. + Configure testing """ import asyncio -import aiohttp import pytest import pytest_asyncio from aiohttp import web +from utils.test_server import WebsocketView @pytest.fixture(scope="session") @@ -18,29 +18,9 @@ def event_loop(): loop.close() -async def websocket_handler(request): - ws = web.WebSocketResponse() - await ws.prepare(request) - - async def cleanup(): - await ws.close(code=aiohttp.WSCloseCode.GOING_AWAY, message="Server shutdown") - - try: - async for msg in ws: - if msg.type == aiohttp.WSMsgType.TEXT: - while True: - await ws.send_str("#MF: 1+ 0+ 2+1\r") - await asyncio.sleep(0.5) - finally: - await asyncio.shield(cleanup()) - - return ws - - @pytest_asyncio.fixture() -async def ws_server(aiohttp_server: web.Server): +async def ws_server(aiohttp_server: "web.Server"): """Websocket test server""" app = web.Application() - - app.add_routes([web.get("/", websocket_handler)]) + app.add_routes([web.view("/", WebsocketView)]) return await aiohttp_server(app, port=3001) diff --git a/tests/test_client.py b/tests/test_client.py index dd91dd1..c1b24b3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,6 @@ +"""Client tests""" import asyncio +import typing import aiohttp import pytest @@ -6,59 +8,35 @@ from pysaleryd.client import Client, State +if typing.TYPE_CHECKING: + from aiohttp import web_server + __author__ = "Björn Dalfors" __copyright__ = "Björn Dalfors" __license__ = "MIT" -async def on_shutdown(app): - """Shutdown ws connections on shutdown""" - for ws in set(app["websockets"]): - try: - await ws.close( - code=aiohttp.WSCloseCode.GOING_AWAY, message="Server shutdown" - ) - except Exception: - pass - - -@pytest_asyncio.fixture -async def hrv_client(ws_server): +@pytest_asyncio.fixture(name="hrv_client") +async def _hrv_client(ws_server): """HRV Client""" - try: - async with aiohttp.ClientSession() as session: - async with Client("localhost", 3001, session) as client: - yield client - except Exception: - pass + async with aiohttp.ClientSession() as session: + async with Client("localhost", 3001, session) as client: + yield client @pytest.mark.asyncio -async def test_client_connect(hrv_client: Client): +async def test_client_connect(hrv_client: "Client"): """test connect""" assert hrv_client.state == State.RUNNING @pytest.mark.asyncio -async def test_client_connect_unsresponsive(): - """test status when client is unresponsive""" - async with aiohttp.ClientSession() as session: - client = Client("localhost", 3002, session) - try: - await client.connect() - except Exception: # noqa: W0718 - pass - - assert client.state == State.STOPPED - - -@pytest.mark.asyncio -async def test_handler(hrv_client: Client, mocker): +async def test_handler(hrv_client: "Client", mocker): """Test handler callback""" handler = mocker.Mock() def broken_handler(data): - raise Exception() + raise Exception() # pylint: disable=W0719 hrv_client.add_handler(broken_handler) hrv_client.add_handler(handler) @@ -67,15 +45,15 @@ def broken_handler(data): @pytest.mark.asyncio -async def test_get_data(hrv_client: Client, mocker): +async def test_get_data(hrv_client: "Client"): """Test get data""" - await asyncio.sleep(1) + await asyncio.sleep(5) assert isinstance(hrv_client.data, dict) assert any(hrv_client.data.keys()) @pytest.mark.asyncio -async def test_reconnect(hrv_client: Client, ws_server): +async def test_reconnect(hrv_client: "Client", ws_server: "web_server.Server"): """Test reconnect""" async def has_state(state): @@ -90,16 +68,16 @@ async def has_state(state): @pytest.mark.asyncio -async def test_send_command(hrv_client: Client, mocker): +async def test_send_command(hrv_client: "Client"): """Test send command""" await hrv_client.send_command("MF", "0") @pytest.mark.asyncio -async def test_disconnect(hrv_client: Client, mocker): +async def test_disconnect(hrv_client: "Client"): """Test send command""" hrv_client.disconnect() await asyncio.sleep(2) assert hrv_client.state == State.STOPPED await asyncio.sleep(2) - assert hrv_client._socket._ws.closed + assert hrv_client._socket._ws.closed # pylint: disable=all diff --git a/tests/test_utils.py b/tests/test_parser.py similarity index 83% rename from tests/test_utils.py rename to tests/test_parser.py index b476668..afeb438 100644 --- a/tests/test_utils.py +++ b/tests/test_parser.py @@ -12,12 +12,13 @@ _LOGGER = logging.getLogger(__name__) -@pytest.fixture -def parser() -> Parser: +@pytest.fixture(name="parser") +def _parser() -> Parser: return Parser() def test_parse_int_from_list_str(parser: Parser): + """Test parsing int list""" (key, value) = parser.from_str("#MF: 1+ 0+ 2+30\r") assert key == "MF" assert isinstance(value, list) @@ -28,6 +29,7 @@ def test_parse_int_from_list_str(parser: Parser): def test_parse_int_from_str(parser: Parser): + """Test parsing int""" (key, value) = parser.from_str("#*XX:0\r") assert key == "*XX" assert isinstance(value, int) @@ -35,6 +37,7 @@ def test_parse_int_from_str(parser: Parser): def test_parse_str_from_str(parser: Parser): + """Test parsing str""" (key, value) = parser.from_str("#*XX:xxx\r") assert key == "*XX" assert isinstance(value, str) @@ -47,9 +50,6 @@ def test_parse_str_from_str(parser: Parser): def test_parse_error(parser: Parser): - did_throw = False - try: + """Test parse error""" + with pytest.raises(ParseError): parser.from_str("wer") - except ParseError: - did_throw = True - assert did_throw diff --git a/tests/test_skeleton.py b/tests/test_skeleton.py index 3bcfce0..a11dc5b 100644 --- a/tests/test_skeleton.py +++ b/tests/test_skeleton.py @@ -1,3 +1,5 @@ +"""CLI Tests""" + import pytest from pysaleryd.skeleton import main @@ -8,7 +10,7 @@ @pytest.mark.skip -def test_main(capsys, ws_server): +def test_main(capsys, ws_server): # pylint: disable W0613 """CLI Tests""" # capsys is a pytest fixture that allows asserts against stdout/stderr # https://docs.pytest.org/en/stable/capture.html diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/test_server.py b/tests/utils/test_server.py new file mode 100644 index 0000000..446e9b3 --- /dev/null +++ b/tests/utils/test_server.py @@ -0,0 +1,92 @@ +"""Websocket server for testing""" + +import asyncio +import logging + +import aiohttp +from aiohttp import web +from aiohttp.web import WebSocketResponse +from aiohttp.web_request import Request + +_LOGGER = logging.getLogger(__name__) + + +class WebsocketView(web.View): + def __init__(self, request: Request) -> None: + self.receive = asyncio.queues.Queue() + self.send = asyncio.queues.Queue() + self._message_handler = None + self._outgoing_message_handler = None + self.stream_handler = None + self._listener = None + self._pinger = None + super().__init__(request) + + @classmethod + async def cleanup(cls, ws): + """Close websocket connection""" + await ws.close(code=aiohttp.WSCloseCode.GOING_AWAY, message="Server shutdown") + + async def pinger(self): + """Send ping to peer""" + while True: + await asyncio.sleep(30) + await self.send.put("PING") + + async def data_generator(self): + """Generate data and push to queue""" + while True: + await self.send.put("#MF: 1+ 1+ 1+1") + await asyncio.sleep(0.5) + + async def outgoing_message_handler(self, ws: WebSocketResponse): + """Send outgoing messages to peer""" + while True: + msg = await self.send.get() + await ws.send_str(msg) + + async def incoming_message_handler(self): + """Handle incoming messages""" + while True: + msg = await self.receive.get() # noqa: F841 + if not self.stream_handler: + self.stream_handler = asyncio.create_task(self.data_generator()) + + async def listener(self, ws: WebSocketResponse): + """Push incoming messages to queue""" + try: + while True: + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + await self.receive.put(msg) + finally: + task = asyncio.create_task(self.cleanup(ws), name="Cleanup") + await asyncio.shield(task) + + async def websocket_handler(self, request): + """Websocket handler""" + ws = aiohttp.web.WebSocketResponse() + await ws.prepare(request) + self._message_handler = asyncio.create_task(self.incoming_message_handler()) + self._outgoing_message_handler = asyncio.create_task( + self.outgoing_message_handler(ws) + ) + self._pinger = asyncio.create_task(self.pinger()) + self._listener = asyncio.create_task(self.listener(ws), name="Serve") + await self._listener + return ws + + async def get(self): + """GET + Serve websocket + """ + return await self.websocket_handler(self.request) + + +def run_server(argv): # pylint: disable W0613 + """Init function when running from cli + See https://docs.aiohttp.org/en/stable/web_quickstart.html#command-line-interface-cli # noqa: E501 + """ + app = web.Application() + app.router.add_view("/", WebsocketView) + return app From 0c0a85b6952f98d575ce099e62ddcbe4882cbf87 Mon Sep 17 00:00:00 2001 From: "bj00rn@users.noreply.github.com" Date: Mon, 4 Dec 2023 16:16:19 +0100 Subject: [PATCH 2/2] chore(ci): implement test server --- .gitignore | 1 - .pre-commit-config.yaml | 7 +++++-- .vscode/launch.json | 30 ++++++++++++++++++++++++++++++ .vscode/settings.json | 18 ++++++++++++++++++ .vscode/tasks.json | 13 +++++++++++++ 5 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json diff --git a/.gitignore b/.gitignore index e9e1e9b..24f9b8a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,6 @@ __pycache__/* .pydevproject .settings .idea -.vscode tags # Package files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f6eb9c2..429d635 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,9 +9,7 @@ repos: - id: trailing-whitespace - id: check-added-large-files - id: check-ast - - id: check-json - id: check-merge-conflict - - id: check-xml - id: check-yaml - id: debug-statements - id: end-of-file-fixer @@ -74,3 +72,8 @@ repos: - id: commitlint stages: [commit-msg] additional_dependencies: ['conventional-changelog-conventionalcommits'] + +- repo: https://gitlab.com/bmares/check-json5 + rev: v1.0.0 + hooks: + - id: check-json5 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d3f9546 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Test server", + "type": "python", + "request": "launch", + "module": "aiohttp.web", + "justMyCode": true, + "args": ["-H", "localhost", "-P", "3001", "tests.utils.test_server:run_server"] + }, + { + "name": "Debug CLI", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/src/pysaleryd/skeleton.py", + "console": "integratedTerminal", + "justMyCode": true, + "args": [ + "--host", + "localhost", + "--port", + "3001" + ] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f49d681 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "esbonio.sphinx.confDir": "", + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": [ + // Coverage is not supported by vscode: + // https://github.com/Microsoft/vscode-python/issues/693 + // Note that this will make pytest fail if pytest-cov is not installed, + // if that's the case, then this option needs to be be removed (overrides + // can be set at a workspace level, it's up to you to decide what's the + // best approach). You might also prefer to only set this option + // per-workspace (wherever coverage is used). + "--no-cov", + "-o", + "log_cli=1", + "--timeout=30", + "--verbose" + ], +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..b6359b2 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Run test server", + "type": "shell", + "command": "python", + "args": ["-m", "aiohttp.web", "-H", "localhost", "-P", "3001", "tests.utils.test_server:run_server"] + } + ] +}