diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index ea48ce6b..6265aff3 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -25,7 +25,6 @@ jobs: run: | pip install -U pip pip install . - pip install -r requirements/async.txt pip install -r requirements/adapter.txt pip install -r requirements/testing.txt pip install -r requirements/adapter_testing.txt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 25d4ecc6..35647154 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + - name: Install synchronous dependencies run: | pip install -U pip pip install -r requirements.txt @@ -28,11 +28,10 @@ jobs: run: | pytest tests/slack_bolt/ pytest tests/scenario_tests/ - - name: Run tests for Socket Mode adapters + - name: Install adapter dependencies run: | pip install -r requirements/adapter.txt pip install -r requirements/adapter_testing.txt - pytest tests/adapter_tests/socket_mode/ - name: Run tests for HTTP Mode adapters (AWS) run: | pytest tests/adapter_tests/aws/ @@ -68,9 +67,15 @@ jobs: - name: Run tests for HTTP Mode adapters (Tornado) run: | pytest tests/adapter_tests/tornado/ - - name: Run tests for HTTP Mode adapters (asyncio-based libraries) + - name: Install async dependencies run: | pip install -r requirements/async.txt + - name: Run tests for Socket Mode adapters + run: | + # Requires async test dependencies + pytest tests/adapter_tests/socket_mode/ + - name: Run tests for HTTP Mode adapters (asyncio-based libraries) + run: | # Falcon supports Python 3.11 since its v3.1.1 pip install "falcon>=3.1.1,<4" pytest tests/adapter_tests_async/ diff --git a/requirements/adapter_testing.txt b/requirements/adapter_testing.txt index f2978a46..27d4d9fa 100644 --- a/requirements/adapter_testing.txt +++ b/requirements/adapter_testing.txt @@ -1,8 +1,5 @@ # pip install -r requirements/adapter_testing.txt -moto>=3,<4 # For AWS tests +moto>=3,<5 # For AWS tests docker>=5,<6 # Used by moto boddle>=0.2,<0.3 # For Bottle app tests -Flask>=1,<2 # TODO: Flask-Sockets is not yet compatible with Flask 2.x -Werkzeug>=1,<2 # TODO: Flask-Sockets is not yet compatible with Flask 2.x sanic-testing>=0.7; python_version>"3.6" -requests>=2,<3 # For Starlette's TestClient diff --git a/requirements/async.txt b/requirements/async.txt index 221ec4ae..f27c3baf 100644 --- a/requirements/async.txt +++ b/requirements/async.txt @@ -1,4 +1,3 @@ # pip install -r requirements/async.txt aiohttp>=3,<4 -websockets>=8,<10; python_version=="3.6" -websockets>=10,<11; python_version>"3.6" +websockets<11 diff --git a/requirements/testing.txt b/requirements/testing.txt index 6fbcc045..7cd7d353 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -1,6 +1,4 @@ # pip install -r requirements/testing.txt -r testing_without_asyncio.txt - -pytest-asyncio>=0.16.0; python_version=="3.6" -pytest-asyncio>=0.18.2,<1; python_version>"3.6" -aiohttp>=3,<4 +-r async.txt +pytest-asyncio<1; diff --git a/requirements/testing_without_asyncio.txt b/requirements/testing_without_asyncio.txt index 0f971986..5421347a 100644 --- a/requirements/testing_without_asyncio.txt +++ b/requirements/testing_without_asyncio.txt @@ -1,9 +1,5 @@ # pip install -r requirements/testing_without_asyncio.txt pytest>=6.2.5,<7 -pytest-cov>=3,<4 -Flask-Sockets>=0.2,<1 # TODO: This module is not yet Flask 2.x compatible -Werkzeug>=1,<2 # TODO: Flask-Sockets is not yet compatible with Flask 2.x -itsdangerous==2.0.1 # TODO: Flask-Sockets is not yet compatible with Flask 2.x -Jinja2==3.0.3 # https://github.com/pallets/flask/issues/4494 +pytest-cov>=3,<5 black==22.8.0 # Until we drop Python 3.6 support, we have to stay with this version click<=8.0.4 # black is affected by https://github.com/pallets/click/issues/2225 diff --git a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py index 5d64d078..99765736 100644 --- a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py +++ b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py @@ -1,10 +1,12 @@ -import json +import asyncio import logging import threading import time -import requests -from typing import List from unittest import TestCase +from urllib.error import URLError +from urllib.request import urlopen + +from aiohttp import WSMsgType, web socket_mode_envelopes = [ """{"envelope_id":"57d6a792-4d35-4d0b-b6aa-3361493e1caf","payload":{"type":"shortcut","token":"xxx","action_ts":"1610198080.300836","team":{"id":"T111","domain":"seratch"},"user":{"id":"U111","username":"seratch","team_id":"T111"},"is_enterprise_install":false,"enterprise":null,"callback_id":"do-something","trigger_id":"111.222.xxx"},"type":"interactive","accepts_response_payload":false}""", @@ -12,67 +14,94 @@ """{"envelope_id":"08cfc559-d933-402e-a5c1-79e135afaae4","payload":{"token":"xxx","team_id":"T111","api_app_id":"A111","event":{"client_msg_id":"c9b466b5-845c-49c6-a371-57ae44359bf1","type":"message","text":"<@W111>","user":"U111","ts":"1610197986.000300","team":"T111","blocks":[{"type":"rich_text","block_id":"1HBPc","elements":[{"type":"rich_text_section","elements":[{"type":"user","user_id":"U111"}]}]}],"channel":"C111","event_ts":"1610197986.000300","channel_type":"channel"},"type":"event_callback","event_id":"Ev111","event_time":1610197986,"authorizations":[{"enterprise_id":null,"team_id":"T111","user_id":"U111","is_bot":true,"is_enterprise_install":false}],"is_ext_shared_channel":false,"event_context":"1-message-T111-C111"},"type":"events_api","accepts_response_payload":false,"retry_attempt":1,"retry_reason":"timeout"}""", ] -from flask import Flask -from flask_sockets import Sockets - def start_thread_socket_mode_server(test: TestCase, port: int): - def _start_thread_socket_mode_server(): - logger = logging.getLogger(__name__) - app: Flask = Flask(__name__) + logger = logging.getLogger(__name__) + state = {} + + def reset_server_state(): + state.update( + envelopes_to_consume=list(socket_mode_envelopes), + ) + + test.reset_server_state = reset_server_state + + async def health(request: web.Request): + wr = web.Response() + await wr.prepare(request) + wr.set_status(200) + return wr + + async def link(request: web.Request): + ws = web.WebSocketResponse() + await ws.prepare(request) + + async for msg in ws: + if msg.type != WSMsgType.TEXT: + continue - @app.route("/state") - def state(): - return json.dumps({"success": True}), 200, {"ContentType": "application/json"} + if state["envelopes_to_consume"]: + e = state["envelopes_to_consume"].pop(0) + logger.debug(f"Send an envelope: {e}") + await ws.send_str(e) - sockets: Sockets = Sockets(app) + message = msg.data + logger.debug(f"Server received a message: {message}") - envelopes_to_consume: List[str] = list(socket_mode_envelopes) + await ws.send_str(message) - @sockets.route("/link") - def link(ws): - while not ws.closed: - message = ws.read_message() - if message is not None: - if len(envelopes_to_consume) > 0: - e = envelopes_to_consume.pop(0) - logger.debug(f"Send an envelope: {e}") - ws.send(e) + return ws - logger.debug(f"Server received a message: {message}") - ws.send(message) + app = web.Application() + app.add_routes( + [ + web.get("/link", link), + web.get("/health", health), + ] + ) + runner = web.AppRunner(app) - from gevent import pywsgi - from geventwebsocket.handler import WebSocketHandler + def run_server(): + reset_server_state() - server = pywsgi.WSGIServer(("", port), app, handler_class=WebSocketHandler) - test.server = server - server.serve_forever(stop_timeout=1) + test.loop = asyncio.new_event_loop() + asyncio.set_event_loop(test.loop) + test.loop.run_until_complete(runner.setup()) + site = web.TCPSite(runner, "127.0.0.1", port, reuse_port=True) + test.loop.run_until_complete(site.start()) - return _start_thread_socket_mode_server + # run until it's stopped from the main thread + test.loop.run_forever() + + test.loop.run_until_complete(runner.cleanup()) + test.loop.close() + + return run_server def start_socket_mode_server(test, port: int): test.sm_thread = threading.Thread(target=start_thread_socket_mode_server(test, port)) test.sm_thread.daemon = True test.sm_thread.start() - wait_for_socket_mode_server(port, 4) # wait for the server + wait_for_socket_mode_server(port, 4) -def wait_for_socket_mode_server(port: int, secs: int): +def wait_for_socket_mode_server(port: int, timeout: int): start_time = time.time() - while (time.time() - start_time) < secs: - response = requests.get(url=f"http://localhost:{port}/state") - if response.ok: - break - time.sleep(0.01) - - -def stop_socket_mode_server(test): - test.server.stop() - test.server.close() - - -async def stop_socket_mode_server_async(test: TestCase): - test.server.stop() - test.server.close() + while (time.time() - start_time) < timeout: + try: + urlopen(f"http://127.0.0.1:{port}/health") + return + except URLError: + time.sleep(0.01) + + +def stop_socket_mode_server(test: TestCase): + # An event loop runs in a thread and executes all callbacks and Tasks in + # its thread. While a Task is running in the event loop, no other Tasks + # can run in the same thread. When a Task executes an await expression, the + # running Task gets suspended, and the event loop executes the next Task. + # To schedule a callback from another OS thread, the loop.call_soon_threadsafe() method should be used. + # https://docs.python.org/3/library/asyncio-dev.html#asyncio-multithreading + test.loop.call_soon_threadsafe(test.loop.stop) + test.sm_thread.join(timeout=5) diff --git a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py index 6e8ce40d..1720f7ec 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py +++ b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py @@ -12,7 +12,7 @@ from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop from ...adapter_tests.socket_mode.mock_socket_mode_server import ( start_socket_mode_server, - stop_socket_mode_server_async, + stop_socket_mode_server, ) @@ -71,4 +71,4 @@ async def command_handler(ack): assert result["command"] is True finally: await handler.client.close() - await stop_socket_mode_server_async(self) + stop_socket_mode_server(self) diff --git a/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py index 6709d588..11268c6a 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py +++ b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py @@ -12,7 +12,7 @@ from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop from ...adapter_tests.socket_mode.mock_socket_mode_server import ( start_socket_mode_server, - stop_socket_mode_server_async, + stop_socket_mode_server, ) @@ -80,4 +80,4 @@ async def lazy_func(body): finally: await handler.client.close() - await stop_socket_mode_server_async(self) + stop_socket_mode_server(self) diff --git a/tests/adapter_tests_async/socket_mode/test_async_websockets.py b/tests/adapter_tests_async/socket_mode/test_async_websockets.py index 19167e63..db2680fc 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_websockets.py +++ b/tests/adapter_tests_async/socket_mode/test_async_websockets.py @@ -12,7 +12,7 @@ from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop from ...adapter_tests.socket_mode.mock_socket_mode_server import ( start_socket_mode_server, - stop_socket_mode_server_async, + stop_socket_mode_server, ) @@ -71,4 +71,4 @@ async def command_handler(ack): assert result["command"] is True finally: await handler.client.close() - await stop_socket_mode_server_async(self) + stop_socket_mode_server(self)