From a7a679c91bb7b2c13c1c9075cd933079a6c62338 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 27 Apr 2020 23:15:35 +0900 Subject: [PATCH 01/12] Modify unit tests not to depend on asyncio --- tests/data/web_response_api_test.json | 3 + tests/data/web_response_api_test_false.json | 3 + ...web_response_channels_list_pagination.json | 11 + ...eb_response_channels_list_pagination2.json | 11 + ...ponse_channels_list_pagination2_page2.json | 8 + ...se_channels_list_pagination_has_page2.json | 11 + ...se_channels_list_pagination_has_page3.json | 8 + .../web_response_users_list_pagination.json | 10 + .../web_response_users_list_pagination_1.json | 10 + tests/data/web_response_users_setPhoto.json | 3 + tests/helpers.py | 73 +--- tests/rtm/mock_web_api_server.py | 97 ++++++ tests/rtm/test_rtm_client.py | 31 +- tests/rtm/test_rtm_client_functional.py | 115 ++++--- tests/web/mock_web_api_server.py | 151 +++++++++ tests/web/test_web_client.py | 174 +++------- tests/web/test_web_client_coverage.py | 311 ++++++++---------- tests/web/test_web_client_functional.py | 31 +- 18 files changed, 586 insertions(+), 475 deletions(-) create mode 100644 tests/data/web_response_api_test.json create mode 100644 tests/data/web_response_api_test_false.json create mode 100644 tests/data/web_response_channels_list_pagination.json create mode 100644 tests/data/web_response_channels_list_pagination2.json create mode 100644 tests/data/web_response_channels_list_pagination2_page2.json create mode 100644 tests/data/web_response_channels_list_pagination_has_page2.json create mode 100644 tests/data/web_response_channels_list_pagination_has_page3.json create mode 100644 tests/data/web_response_users_list_pagination.json create mode 100644 tests/data/web_response_users_list_pagination_1.json create mode 100644 tests/data/web_response_users_setPhoto.json create mode 100644 tests/rtm/mock_web_api_server.py create mode 100644 tests/web/mock_web_api_server.py diff --git a/tests/data/web_response_api_test.json b/tests/data/web_response_api_test.json new file mode 100644 index 000000000..47971aa3c --- /dev/null +++ b/tests/data/web_response_api_test.json @@ -0,0 +1,3 @@ +{ + "ok": true +} \ No newline at end of file diff --git a/tests/data/web_response_api_test_false.json b/tests/data/web_response_api_test_false.json new file mode 100644 index 000000000..8cba4747d --- /dev/null +++ b/tests/data/web_response_api_test_false.json @@ -0,0 +1,3 @@ +{ + "ok": false +} \ No newline at end of file diff --git a/tests/data/web_response_channels_list_pagination.json b/tests/data/web_response_channels_list_pagination.json new file mode 100644 index 000000000..9d4b9b847 --- /dev/null +++ b/tests/data/web_response_channels_list_pagination.json @@ -0,0 +1,11 @@ +{ + "ok": true, + "channels": [ + { + "id": "C1" + } + ], + "response_metadata": { + "next_cursor": "has_page2" + } +} \ No newline at end of file diff --git a/tests/data/web_response_channels_list_pagination2.json b/tests/data/web_response_channels_list_pagination2.json new file mode 100644 index 000000000..8709f7938 --- /dev/null +++ b/tests/data/web_response_channels_list_pagination2.json @@ -0,0 +1,11 @@ +{ + "ok": true, + "channels": [ + { + "id": "C2" + } + ], + "response_metadata": { + "next_cursor": "page2" + } +} \ No newline at end of file diff --git a/tests/data/web_response_channels_list_pagination2_page2.json b/tests/data/web_response_channels_list_pagination2_page2.json new file mode 100644 index 000000000..5175f163c --- /dev/null +++ b/tests/data/web_response_channels_list_pagination2_page2.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "channels": [ + { + "id": "C3" + } + ] +} \ No newline at end of file diff --git a/tests/data/web_response_channels_list_pagination_has_page2.json b/tests/data/web_response_channels_list_pagination_has_page2.json new file mode 100644 index 000000000..f34bd4f2c --- /dev/null +++ b/tests/data/web_response_channels_list_pagination_has_page2.json @@ -0,0 +1,11 @@ +{ + "ok": true, + "channels": [ + { + "id": "C2" + } + ], + "response_metadata": { + "next_cursor": "has_page3" + } +} \ No newline at end of file diff --git a/tests/data/web_response_channels_list_pagination_has_page3.json b/tests/data/web_response_channels_list_pagination_has_page3.json new file mode 100644 index 000000000..5175f163c --- /dev/null +++ b/tests/data/web_response_channels_list_pagination_has_page3.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "channels": [ + { + "id": "C3" + } + ] +} \ No newline at end of file diff --git a/tests/data/web_response_users_list_pagination.json b/tests/data/web_response_users_list_pagination.json new file mode 100644 index 000000000..11019de02 --- /dev/null +++ b/tests/data/web_response_users_list_pagination.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "members": [ + "Bob", + "cat" + ], + "response_metadata": { + "next_cursor": 1 + } +} \ No newline at end of file diff --git a/tests/data/web_response_users_list_pagination_1.json b/tests/data/web_response_users_list_pagination_1.json new file mode 100644 index 000000000..e9e4ca59e --- /dev/null +++ b/tests/data/web_response_users_list_pagination_1.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "members": [ + "Kevin", + "dog" + ], + "response_metadata": { + "next_cursor": "" + } +} \ No newline at end of file diff --git a/tests/data/web_response_users_setPhoto.json b/tests/data/web_response_users_setPhoto.json new file mode 100644 index 000000000..47971aa3c --- /dev/null +++ b/tests/data/web_response_users_setPhoto.json @@ -0,0 +1,3 @@ +{ + "ok": true +} \ No newline at end of file diff --git a/tests/helpers.py b/tests/helpers.py index 9f71964cf..71673203e 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,81 +1,12 @@ -# Standard Imports -from unittest.mock import ANY, Mock - -# ThirdParty Imports import asyncio -# Internal Imports -from slack.web.slack_response import SlackResponse - - -def fake_req_args(headers=ANY, data=ANY, params=ANY, json=ANY): - req_args = { - "headers": headers, - "data": data, - "params": params, - "json": json, - "ssl": ANY, - "proxy": ANY, - "auth": ANY, - } - return req_args - - -def fake_send_req_args(headers=ANY, data=ANY, params=ANY, json=ANY): - req_args = { - "headers": headers, - "data": data, - "params": params, - "json": json, - "ssl": ANY, - "proxy": ANY, - "files": ANY, - "auth": ANY, - } - return req_args - - -def mock_rtm_response(): - coro = Mock(name="RTMResponse") - data = { - "client": ANY, - "http_verb": ANY, - "api_url": ANY, - "req_args": ANY, - "data": { - "ok": True, - "url": "ws://localhost:8765", - "self": {"id": "U01234ABC", "name": "robotoverlord"}, - "team": { - "domain": "exampledomain", - "id": "T123450FP", - "name": "ExampleName", - }, - }, - "headers": ANY, - "status_code": 200, - } - coro.return_value = SlackResponse(**data) - corofunc = Mock(name="mock_rtm_response", side_effect=asyncio.coroutine(coro)) - corofunc.coro = coro - return corofunc - def async_test(coro): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) def wrapper(*args, **kwargs): - return asyncio.get_event_loop().run_until_complete(coro(*args, **kwargs)) + future = coro(*args, **kwargs) + return asyncio.get_event_loop().run_until_complete(future) return wrapper - - -def mock_request(): - response_mock = Mock(name="Response") - data = {"data": {"ok": True}, "headers": ANY, "status_code": 200} - response_mock.return_value = data - - send_request = Mock(name="Request", side_effect=asyncio.coroutine(response_mock)) - send_request.response = response_mock - return send_request diff --git a/tests/rtm/mock_web_api_server.py b/tests/rtm/mock_web_api_server.py new file mode 100644 index 000000000..cd1047937 --- /dev/null +++ b/tests/rtm/mock_web_api_server.py @@ -0,0 +1,97 @@ +import json +import logging +import threading +from http import HTTPStatus +from http.server import HTTPServer, SimpleHTTPRequestHandler +from typing import Type +from unittest import TestCase + + +class MockHandler(SimpleHTTPRequestHandler): + protocol_version = "HTTP/1.1" + default_request_version = "HTTP/1.1" + logger = logging.getLogger(__name__) + + def is_valid_token(self): + return "authorization" in self.headers \ + and str(self.headers["authorization"]).startswith("Bearer xoxb-") + + def is_invalid_rtm_start(self): + return "authorization" in self.headers \ + and str(self.headers["authorization"]).startswith("Bearer xoxb-rtm.start") \ + and str(self.path) != '/rtm.start' + + def set_common_headers(self): + self.send_header("content-type", "application/json;charset=utf-8") + self.send_header("connection", "close") + self.end_headers() + + rtm_start_success = { + "ok": True, + "url": "ws://localhost:8765", + "self": {"id": "U01234ABC", "name": "robotoverlord"}, + "team": { + "domain": "exampledomain", + "id": "T123450FP", + "name": "ExampleName", + }, + } + + rtm_start_failure = { + "ok": False, + "error": "invalid_auth", + } + + def _handle(self): + if self.is_invalid_rtm_start(): + self.send_response(HTTPStatus.BAD_REQUEST) + self.set_common_headers() + return + + self.send_response(HTTPStatus.OK) + self.set_common_headers() + body = self.rtm_start_success if self.is_valid_token() else self.rtm_start_failure + self.wfile.write(json.dumps(body).encode("utf-8")) + self.wfile.close() + + def do_GET(self): + self._handle() + + def do_POST(self): + self._handle() + + +class MockServerThread(threading.Thread): + + def __init__(self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler): + threading.Thread.__init__(self) + self.handler = handler + self.test = test + + def run(self): + self.server = HTTPServer(('localhost', 8888), self.handler) + self.test.server_url = "http://localhost:8888" + self.test.host, self.test.port = self.server.socket.getsockname() + self.test.server_started.set() # threading.Event() + + self.test = None + try: + self.server.serve_forever(0.05) + finally: + self.server.server_close() + + def stop(self): + self.server.shutdown() + self.join() + + +def setup_mock_web_api_server(test: TestCase): + test.server_started = threading.Event() + test.thread = MockServerThread(test) + test.thread.start() + test.server_started.wait() + + +def cleanup_mock_web_api_server(test: TestCase): + test.thread.stop() + test.thread = None diff --git a/tests/rtm/test_rtm_client.py b/tests/rtm/test_rtm_client.py index 73c779a5e..3faa67fed 100644 --- a/tests/rtm/test_rtm_client.py +++ b/tests/rtm/test_rtm_client.py @@ -1,27 +1,30 @@ -# Standard Imports +import asyncio import collections import unittest -from unittest import mock -import asyncio -# Internal Imports import slack import slack.errors as e -from tests.helpers import mock_rtm_response +from tests.rtm.mock_web_api_server import setup_mock_web_api_server, cleanup_mock_web_api_server class TestRTMClient(unittest.TestCase): def setUp(self): - self.client = slack.RTMClient(token="xoxp-1234", auto_reconnect=False, loop=asyncio.get_event_loop()) + setup_mock_web_api_server(self) + self.client = slack.RTMClient( + token="xoxp-1234", + base_url="http://localhost:8888", + auto_reconnect=False + ) def tearDown(self): + cleanup_mock_web_api_server(self) slack.RTMClient._callbacks = collections.defaultdict(list) def test_run_on_returns_callback(self): @slack.RTMClient.run_on(event="message") def fn_used_elsewhere(**_unused_payload): pass - + self.assertIsNotNone(fn_used_elsewhere) self.assertEqual(fn_used_elsewhere.__name__, "fn_used_elsewhere") @@ -81,18 +84,10 @@ def test_send_over_websocket_raises_when_not_connected(self): error = str(context.exception) self.assertIn(expected_error, error) - @mock.patch("slack.WebClient._send", new_callable=mock_rtm_response) - def test_start_raises_an_error_if_rtm_ws_url_is_not_returned( - self, mock_rtm_response - ): - mock_rtm_response.coro.return_value = { - "data": {"ok": True}, - "headers": {}, - "status_code": 200, - } - + def test_start_raises_an_error_if_rtm_ws_url_is_not_returned(self): with self.assertRaises(e.SlackApiError) as context: slack.RTMClient(token="xoxp-1234", auto_reconnect=False).start() - expected_error = "Unable to retrieve RTM URL from Slack" + expected_error = "The request to the Slack API failed.\n" \ + "The server responded with: {'ok': False, 'error': 'invalid_auth'}" self.assertIn(expected_error, str(context.exception)) diff --git a/tests/rtm/test_rtm_client_functional.py b/tests/rtm/test_rtm_client_functional.py index 70ec7ea7e..950b5f1ca 100644 --- a/tests/rtm/test_rtm_client_functional.py +++ b/tests/rtm/test_rtm_client_functional.py @@ -1,26 +1,43 @@ -# Standard Imports +import asyncio import collections import unittest -from unittest import mock -# ThirdParty Imports -import asyncio from aiohttp import web, WSCloseCode -import json -# Internal Imports import slack import slack.errors as e -from tests.helpers import fake_send_req_args, mock_rtm_response +from tests.rtm.mock_web_api_server import setup_mock_web_api_server, cleanup_mock_web_api_server -@mock.patch("slack.WebClient._send", new_callable=mock_rtm_response) class TestRTMClientFunctional(unittest.TestCase): - async def echo(self, ws, path): - async for message in ws: - await ws.send( - json.dumps({"type": "message", "message_sent": json.loads(message)}) - ) + def setUp(self): + setup_mock_web_api_server(self) + + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + task = asyncio.ensure_future(self.mock_server(), loop=self.loop) + self.loop.run_until_complete(asyncio.wait_for(task, 0.1)) + + self.client = slack.RTMClient( + token="xoxb-valid", + base_url="http://localhost:8765", + auto_reconnect=False + ) + self.web_client_loop = asyncio.new_event_loop() + self.client._web_client = slack.WebClient( + token="xoxb-valid", + base_url="http://localhost:8888", + loop=self.web_client_loop, + ) + + def tearDown(self): + self.loop.run_until_complete(self.site.stop()) + cleanup_mock_web_api_server(self) + if self.client: + self.client.stop() + slack.RTMClient._callbacks = collections.defaultdict(list) + + # ------------------------------------------- async def mock_server(self): app = web.Application() @@ -48,22 +65,9 @@ async def on_shutdown(self, app): for ws in set(app["websockets"]): await ws.close(code=WSCloseCode.GOING_AWAY, message="Server shutdown") - def setUp(self): - self.loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.loop) - task = asyncio.ensure_future(self.mock_server(), loop=self.loop) - self.loop.run_until_complete(asyncio.wait_for(task, 0.1)) - self.client = slack.RTMClient( - token="xoxa-1234", loop=self.loop, auto_reconnect=False - ) + # ------------------------------------------- - def tearDown(self): - self.loop.run_until_complete(self.site.stop()) - slack.RTMClient._callbacks = collections.defaultdict(list) - - def test_client_auto_reconnects_if_connection_randomly_closes( - self, mock_rtm_response - ): + def test_client_auto_reconnects_if_connection_randomly_closes(self): @slack.RTMClient.run_on(event="open") def stop_on_open(**payload): rtm_client = payload["rtm_client"] @@ -74,10 +78,10 @@ def stop_on_open(**payload): self.assertEqual(rtm_client._connection_attempts, 2) rtm_client.stop() - client = slack.RTMClient(token="xoxa-1234", auto_reconnect=True, loop=self.loop) - client.start() + self.client.auto_reconnect = True + self.client.start() - def test_client_auto_reconnects_if_an_error_is_thrown(self, mock_rtm_response): + def test_client_auto_reconnects_if_an_error_is_thrown(self): @slack.RTMClient.run_on(event="open") def stop_on_open(**payload): rtm_client = payload["rtm_client"] @@ -88,10 +92,10 @@ def stop_on_open(**payload): self.assertEqual(rtm_client._connection_attempts, 2) rtm_client.stop() - client = slack.RTMClient(token="xoxa-1234", auto_reconnect=True, loop=self.loop) - client.start() + self.client.auto_reconnect = True + self.client.start() - def test_open_event_receives_expected_arguments(self, mock_rtm_response): + def test_open_event_receives_expected_arguments(self): @slack.RTMClient.run_on(event="open") def stop_on_open(**payload): self.assertIsInstance(payload["data"], dict) @@ -102,7 +106,7 @@ def stop_on_open(**payload): self.client.start() - def test_stop_closes_websocket(self, mock_rtm_response): + def test_stop_closes_websocket(self): @slack.RTMClient.run_on(event="open") def stop_on_open(**payload): self.assertFalse(self.client._websocket.closed) @@ -113,7 +117,7 @@ def stop_on_open(**payload): self.client.start() self.assertIsNone(self.client._websocket) - def test_start_calls_rtm_connect_by_default(self, mock_rtm_response): + def test_start_calls_rtm_connect_by_default(self): @slack.RTMClient.run_on(event="open") def stop_on_open(**payload): self.assertFalse(self.client._websocket.closed) @@ -121,28 +125,19 @@ def stop_on_open(**payload): rtm_client.stop() self.client.start() - mock_rtm_response.assert_called_once_with( - http_verb="GET", - api_url="https://www.slack.com/api/rtm.connect", - req_args=fake_send_req_args(), - ) - def test_start_calls_rtm_start_when_specified(self, mock_rtm_response): + def test_start_calls_rtm_start_when_specified(self): @slack.RTMClient.run_on(event="open") def stop_on_open(**payload): self.assertFalse(self.client._websocket.closed) rtm_client = payload["rtm_client"] rtm_client.stop() + self.client.token = "xoxb-rtm.start" self.client.connect_method = "rtm.start" self.client.start() - mock_rtm_response.assert_called_once_with( - http_verb="GET", - api_url="https://www.slack.com/api/rtm.start", - req_args=fake_send_req_args(), - ) - def test_send_over_websocket_sends_expected_message(self, mock_rtm_response): + def test_send_over_websocket_sends_expected_message(self): @slack.RTMClient.run_on(event="open") def echo_message(**payload): rtm_client = payload["rtm_client"] @@ -168,7 +163,7 @@ def check_message(**payload): self.client.start() - def test_ping_sends_expected_message(self, mock_rtm_response): + def test_ping_sends_expected_message(self): @slack.RTMClient.run_on(event="open") async def ping_message(**payload): rtm_client = payload["rtm_client"] @@ -183,7 +178,7 @@ def check_message(**payload): self.client.start() - def test_typing_sends_expected_message(self, mock_rtm_response): + def test_typing_sends_expected_message(self): @slack.RTMClient.run_on(event="open") async def typing_message(**payload): rtm_client = payload["rtm_client"] @@ -198,21 +193,22 @@ def check_message(**payload): self.client.start() - def test_on_error_callbacks(self, mock_rtm_response): + def test_on_error_callbacks(self): @slack.RTMClient.run_on(event="open") def raise_an_error(**payload): raise e.SlackClientNotConnectedError("Testing error handling.") + self.called = False + @slack.RTMClient.run_on(event="error") def error_callback(**payload): - self.error_hanlding_mock(str(payload["data"])) + self.called = True - self.error_hanlding_mock = mock.Mock() with self.assertRaises(e.SlackClientNotConnectedError): self.client.start() - self.error_hanlding_mock.assert_called_once() + self.assertTrue(self.called) - def test_callback_errors_are_raised(self, mock_rtm_response): + def test_callback_errors_are_raised(self): @slack.RTMClient.run_on(event="open") def raise_an_error(**payload): raise Exception("Testing error handling.") @@ -223,15 +219,16 @@ def raise_an_error(**payload): expected_error = "Testing error handling." self.assertIn(expected_error, str(context.exception)) - def test_on_close_callbacks(self, mock_rtm_response): + def test_on_close_callbacks(self): @slack.RTMClient.run_on(event="open") def stop_on_open(**payload): payload["rtm_client"].stop() + self.called = False + @slack.RTMClient.run_on(event="close") def assert_on_close(**payload): - self.close_mock(str(payload["data"])) + self.called = True - self.close_mock = mock.Mock() self.client.start() - self.close_mock.assert_called_once() + self.assertTrue(self.called) diff --git a/tests/web/mock_web_api_server.py b/tests/web/mock_web_api_server.py new file mode 100644 index 000000000..37bc69811 --- /dev/null +++ b/tests/web/mock_web_api_server.py @@ -0,0 +1,151 @@ +import json +import logging +import re +import threading +from http import HTTPStatus +from http.server import HTTPServer, SimpleHTTPRequestHandler +from typing import Type +from unittest import TestCase +from urllib.parse import urlparse, parse_qs + + +class MockHandler(SimpleHTTPRequestHandler): + protocol_version = "HTTP/1.1" + default_request_version = "HTTP/1.1" + logger = logging.getLogger(__name__) + + pattern_for_language = re.compile("python/(\\S+)", re.IGNORECASE) + pattern_for_package_identifier = re.compile("slackclient/(\\S+)") + + def is_valid_user_agent(self): + user_agent = self.headers["User-Agent"] + return self.pattern_for_language.search(user_agent) \ + and self.pattern_for_package_identifier.search(user_agent) + + def is_valid_token(self): + if self.path.startswith("oauth"): + return True + else: + return "Authorization" in self.headers \ + and str(self.headers["Authorization"]).startswith("Bearer xoxb-") + + def set_common_headers(self): + self.send_header("content-type", "application/json;charset=utf-8") + self.send_header("connection", "close") + self.end_headers() + + invalid_auth = { + "ok": False, + "error": "invalid_auth", + } + + not_found = { + "ok": False, + "error": "test_data_not_found", + } + + def _handle(self): + try: + if self.is_valid_token() and self.is_valid_user_agent(): + parsed_path = urlparse(self.path) + + len_header = self.headers.get('Content-Length') or 0 + content_len = int(len_header) + post_body = self.rfile.read(content_len) + request_body = None + if post_body: + try: + post_body = post_body.decode("utf-8") + if post_body.startswith("{"): + request_body = json.loads(post_body) + else: + request_body = {k: v[0] for k, v in parse_qs(post_body).items()} + except UnicodeDecodeError: + pass + else: + if parsed_path and parsed_path.query: + request_body = {k: v[0] for k, v in parse_qs(parsed_path.query).items()} + + header = self.headers["authorization"] + pattern = str(header).split("xoxb-", 1)[1] + if pattern.isnumeric(): + self.send_response(int(pattern)) + self.set_common_headers() + self.wfile.write("""{"ok":false}""".encode("utf-8")) + return + if pattern == "rate_limited": + self.send_response(429) + self.send_header("Retry-After", 30) + self.set_common_headers() + self.wfile.write("""{"ok":false,"error":"rate_limited"}""".encode("utf-8")) + self.wfile.close() + return + + if request_body and "cursor" in request_body: + page = request_body["cursor"] + pattern = f"{pattern}_{page}" + if pattern == "coverage": + body = {"ok": True, "method": parsed_path.path.replace("/", "")} + else: + with open(f"tests/data/web_response_{pattern}.json") as file: + body = json.load(file) + + if self.path == "/api.test" and request_body: + body["args"] = request_body + + else: + body = self.invalid_auth + + if not body: + body = self.not_found + + self.send_response(HTTPStatus.OK) + self.set_common_headers() + self.wfile.write(json.dumps(body).encode("utf-8")) + self.wfile.close() + + except Exception as e: + self.logger.error(str(e), exc_info=True) + raise + + def do_GET(self): + self._handle() + + def do_POST(self): + self._handle() + + +class MockServerThread(threading.Thread): + + def __init__(self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler): + threading.Thread.__init__(self) + self.handler = handler + self.test = test + + def run(self): + self.server = HTTPServer(('localhost', 8888), self.handler) + self.test.server_url = "http://localhost:8888" + self.test.host, self.test.port = self.server.socket.getsockname() + self.test.server_started.set() # threading.Event() + + self.test = None + try: + self.server.serve_forever(0.05) + finally: + self.server.server_close() + + def stop(self): + self.server.shutdown() + self.join() + + +def setup_mock_web_api_server(test: TestCase): + test.server_started = threading.Event() + test.thread = MockServerThread(test) + test.thread.start() + test.server_started.wait() + + +def cleanup_mock_web_api_server(test: TestCase): + test.thread.stop() + test.thread = None diff --git a/tests/web/test_web_client.py b/tests/web/test_web_client.py index 4709d8cc2..255acffd0 100644 --- a/tests/web/test_web_client.py +++ b/tests/web/test_web_client.py @@ -1,118 +1,65 @@ -# Standard Imports -import unittest -from unittest import mock import asyncio import re +import unittest - -# Internal Imports import slack -from tests.helpers import async_test, fake_req_args, mock_request import slack.errors as err +from tests.helpers import async_test +from tests.web.mock_web_api_server import setup_mock_web_api_server, cleanup_mock_web_api_server -@mock.patch("slack.WebClient._request", new_callable=mock_request) class TestWebClient(unittest.TestCase): + def setUp(self): - self.client = slack.WebClient("xoxb-abc-123", loop=asyncio.get_event_loop()) + setup_mock_web_api_server(self) + self.client = slack.WebClient( + token="xoxp-1234", + base_url="http://localhost:8888", + ) def tearDown(self): - pass + cleanup_mock_web_api_server(self) pattern_for_language = re.compile("python/(\\S+)", re.IGNORECASE) pattern_for_package_identifier = re.compile("slackclient/(\\S+)") - def test_api_calls_return_a_response_when_run_in_sync_mode(self, mock_request): + def test_api_calls_return_a_response_when_run_in_sync_mode(self): + self.client.token = "xoxb-api_test" resp = self.client.api_test() self.assertFalse(asyncio.isfuture(resp)) self.assertTrue(resp["ok"]) - def test_api_calls_include_user_agent(self, mock_request): - self.client.api_test() - mock_call_kwargs = mock_request.call_args[1] - self.assertIn("req_args", mock_call_kwargs) - mock_call_req_args = mock_call_kwargs["req_args"] - self.assertIn("headers", mock_call_req_args) - mock_call_headers = mock_call_req_args["headers"] - self.assertIn("User-Agent", mock_call_headers) - mock_call_user_agent = mock_call_headers["User-Agent"] - self.assertRegex( - mock_call_user_agent, - self.pattern_for_package_identifier, - "User Agent contains slackclient and version", - ) - self.assertRegex( - mock_call_user_agent, - self.pattern_for_language, - "User Agent contains Python and version", - ) + def test_api_calls_include_user_agent(self): + self.client.token = "xoxb-api_test" + resp = self.client.api_test() + self.assertEqual(200, resp.status_code) @async_test - async def test_api_calls_return_a_future_when_run_in_async_mode(self, mock_request): + async def test_api_calls_return_a_future_when_run_in_async_mode(self): + self.client.token = "xoxb-api_test" self.client.run_async = True future = self.client.api_test() self.assertTrue(asyncio.isfuture(future)) resp = await future + self.assertEqual(200, resp.status_code) self.assertTrue(resp["ok"]) - def test_builtin_api_methods_send_json(self, mock_request): - self.client.api_test(msg="bye") - mock_request.assert_called_once_with( - http_verb="POST", - api_url="https://www.slack.com/api/api.test", - req_args=fake_req_args(json={"msg": "bye"}), - ) - - def test_requests_can_be_paginated(self, mock_request): - mock_request.response.side_effect = [ - { - "data": { - "ok": True, - "members": ["Bob", "cat"], - "response_metadata": {"next_cursor": 1}, - }, - "status_code": 200, - "headers": {}, - }, - { - "data": {"ok": True, "members": ["Kevin", "dog"]}, - "status_code": 200, - "headers": {}, - }, - ] + def test_builtin_api_methods_send_json(self): + self.client.token = "xoxb-api_test" + resp = self.client.api_test(msg="bye") + self.assertEqual(200, resp.status_code) + self.assertEqual("bye", resp["args"]["msg"]) + def test_requests_can_be_paginated(self): + self.client.token = "xoxb-users_list_pagination" users = [] for page in self.client.users_list(limit=2): users = users + page["members"] self.assertTrue(len(users) == 4) - def test_response_can_be_paginated_multiple_times(self, mock_request): + def test_response_can_be_paginated_multiple_times(self): + self.client.token = "xoxb-channels_list_pagination" # This test suite verifies the changes in #521 work as expected - page1 = { - "data": { - "ok": True, - "channels": [{"id": "C1"}], - "response_metadata": {"next_cursor": "has_page2"}, - }, - "status_code": 200, - "headers": {}, - } - page2 = { - "data": { - "ok": True, - "channels": [{"id": "C2"}], - "response_metadata": {"next_cursor": "has_page3"}, - }, - "status_code": 200, - "headers": {}, - } - page3 = { - "data": {"ok": True, "channels": [{"id": "C3"}]}, - "status_code": 200, - "headers": {}, - } - # The initial pagination - mock_request.response.side_effect = [page1, page2, page3] response = self.client.channels_list(limit=1) ids = [] for page in response: @@ -121,68 +68,41 @@ def test_response_can_be_paginated_multiple_times(self, mock_request): # The second iteration starting with page 2 # (page1 is already cached in `response`) - mock_request.response.side_effect = [page2, page3] + self.client.token = "xoxb-channels_list_pagination2" ids = [] for page in response: ids.append(page["channels"][0]["id"]) self.assertEqual(ids, ["C1", "C2", "C3"]) - def test_request_pagination_stops_when_next_cursor_is_missing(self, mock_request): - mock_request.response.side_effect = [ - { - "data": {"ok": True, "members": ["Bob", "cat"]}, - "status_code": 200, - "headers": {}, - }, - { - "data": {"ok": True, "members": ["Kevin", "dog"]}, - "status_code": 200, - "headers": {}, - }, - ] - + def test_request_pagination_stops_when_next_cursor_is_missing(self): + self.client.token = "xoxb-users_list_pagination_1" users = [] for page in self.client.users_list(limit=2): users = users + page["members"] self.assertTrue(len(users) == 2) - mock_request.assert_called_once_with( - http_verb="GET", - api_url="https://www.slack.com/api/users.list", - req_args=fake_req_args(params={"limit": 2}), - ) - def test_json_can_only_be_sent_with_post_requests(self, mock_request): + def test_json_can_only_be_sent_with_post_requests(self): with self.assertRaises(err.SlackRequestError): self.client.api_call("fake.method", http_verb="GET", json={}) - def test_slack_api_error_is_raised_on_unsuccessful_responses(self, mock_request): - mock_request.response.side_effect = [ - {"data": {"ok": False}, "status_code": 200, "headers": {}}, - {"data": {"ok": True}, "status_code": 500, "headers": {}}, - ] + def test_slack_api_error_is_raised_on_unsuccessful_responses(self): + self.client.token = "xoxb-api_test_false" with self.assertRaises(err.SlackApiError): self.client.api_test() + self.client.token = "xoxb-500" with self.assertRaises(err.SlackApiError): self.client.api_test() - def test_slack_api_rate_limiting_exception_returns_retry_after(self, mock_request): - mock_request.response.side_effect = [ - {"data": {"ok": False}, "status_code": 429, "headers": {"Retry-After": 30}} - ] - with self.assertRaises(err.SlackApiError) as context: + def test_slack_api_rate_limiting_exception_returns_retry_after(self): + self.client.token = "xoxb-rate_limited" + try: self.client.api_test() - slack_api_error = context.exception - self.assertFalse(slack_api_error.response["ok"]) - self.assertEqual(429, slack_api_error.response.status_code) - self.assertEqual(30, slack_api_error.response.headers["Retry-After"]) - - def test_the_api_call_files_argument_creates_the_expected_data(self, mock_request): - self.client.token = "xoxa-123" - with mock.patch("builtins.open", mock.mock_open(read_data="fake")): - self.client.users_setPhoto(image="/fake/path") - - mock_request.assert_called_once_with( - http_verb="POST", - api_url="https://www.slack.com/api/users.setPhoto", - req_args=fake_req_args(), - ) + except Exception as slack_api_error: + self.assertFalse(slack_api_error.response["ok"]) + self.assertEqual(429, slack_api_error.response.status_code) + self.assertEqual(30, int(slack_api_error.response.headers["Retry-After"])) + + def test_the_api_call_files_argument_creates_the_expected_data(self): + self.client.token = "xoxb-users_setPhoto" + resp = self.client.users_setPhoto(image="tests/data/slack_logo.png") + self.assertEqual(200, resp.status_code) diff --git a/tests/web/test_web_client_coverage.py b/tests/web/test_web_client_coverage.py index 3802dfbb0..8c7334433 100644 --- a/tests/web/test_web_client_coverage.py +++ b/tests/web/test_web_client_coverage.py @@ -1,17 +1,12 @@ -# Standard Imports import unittest -# ThirdParty Imports -import asyncio -from aiohttp import web - -# Internal Imports import slack +from tests.web.mock_web_api_server import setup_mock_web_api_server, cleanup_mock_web_api_server class TestWebClientCoverage(unittest.TestCase): # as of March 16, 2020 - # Can be fetched by running `var methodNames = [].slice.call(document.getElementsByClassName('bold')).map(e => e.text);console.log(methodNames.toString());console.log(methodNames.length);` on https://api.slack.com/methods + # Can be fetched by running `var methodNames = [].slice.call(document.getElementsByClassName('bold')["method"]).map(e => e.text);console.log(methodNames.toString()["method"]);console.log(methodNames.length);` on https://api.slack.com/methods all_api_methods = "admin.apps.approve,admin.apps.restrict,admin.apps.approved.list,admin.apps.requests.list,admin.apps.restricted.list,admin.conversations.setTeams,admin.emoji.add,admin.emoji.addAlias,admin.emoji.list,admin.emoji.remove,admin.emoji.rename,admin.inviteRequests.approve,admin.inviteRequests.deny,admin.inviteRequests.list,admin.inviteRequests.approved.list,admin.inviteRequests.denied.list,admin.teams.admins.list,admin.teams.create,admin.teams.list,admin.teams.owners.list,admin.teams.settings.info,admin.teams.settings.setDefaultChannels,admin.teams.settings.setDescription,admin.teams.settings.setDiscoverability,admin.teams.settings.setIcon,admin.teams.settings.setName,admin.users.assign,admin.users.invite,admin.users.list,admin.users.remove,admin.users.setAdmin,admin.users.setExpiration,admin.users.setOwner,admin.users.setRegular,admin.users.session.reset,api.test,apps.permissions.info,apps.permissions.request,apps.permissions.resources.list,apps.permissions.scopes.list,apps.permissions.users.list,apps.permissions.users.request,apps.uninstall,auth.revoke,auth.test,bots.info,chat.delete,chat.deleteScheduledMessage,chat.getPermalink,chat.meMessage,chat.postEphemeral,chat.postMessage,chat.scheduleMessage,chat.unfurl,chat.update,chat.scheduledMessages.list,conversations.archive,conversations.close,conversations.create,conversations.history,conversations.info,conversations.invite,conversations.join,conversations.kick,conversations.leave,conversations.list,conversations.members,conversations.open,conversations.rename,conversations.replies,conversations.setPurpose,conversations.setTopic,conversations.unarchive,dialog.open,dnd.endDnd,dnd.endSnooze,dnd.info,dnd.setSnooze,dnd.teamInfo,emoji.list,files.comments.delete,files.delete,files.info,files.list,files.revokePublicURL,files.sharedPublicURL,files.upload,files.remote.add,files.remote.info,files.remote.list,files.remote.remove,files.remote.share,files.remote.update,migration.exchange,oauth.access,oauth.token,oauth.v2.access,pins.add,pins.list,pins.remove,reactions.add,reactions.get,reactions.list,reactions.remove,reminders.add,reminders.complete,reminders.delete,reminders.info,reminders.list,rtm.connect,rtm.start,search.all,search.files,search.messages,stars.add,stars.list,stars.remove,team.accessLogs,team.billableInfo,team.info,team.integrationLogs,team.profile.get,usergroups.create,usergroups.disable,usergroups.enable,usergroups.list,usergroups.update,usergroups.users.list,usergroups.users.update,users.conversations,users.deletePhoto,users.getPresence,users.identity,users.info,users.list,users.lookupByEmail,users.setActive,users.setPhoto,users.setPresence,users.profile.get,users.profile.set,views.open,views.publish,views.push,views.update,channels.archive,channels.create,channels.history,channels.info,channels.invite,channels.join,channels.kick,channels.leave,channels.list,channels.mark,channels.rename,channels.replies,channels.setPurpose,channels.setTopic,channels.unarchive,groups.archive,groups.create,groups.createChild,groups.history,groups.info,groups.invite,groups.kick,groups.leave,groups.list,groups.mark,groups.open,groups.rename,groups.replies,groups.setPurpose,groups.setTopic,groups.unarchive,im.close,im.history,im.list,im.mark,im.open,im.replies,mpim.close,mpim.history,mpim.list,mpim.mark,mpim.open,mpim.replies".split( "," ) @@ -19,18 +14,12 @@ class TestWebClientCoverage(unittest.TestCase): api_methods_to_call = [] def setUp(self): - self.loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.loop) - task = asyncio.ensure_future(self.mock_server(), loop=self.loop) - self.loop.run_until_complete(asyncio.wait_for(task, 0.3)) - self.client = slack.WebClient( - token="xoxb-abc-123", base_url="http://localhost:8765", loop=self.loop - ) - self.no_token_client = slack.WebClient( - base_url="http://localhost:8765", loop=self.loop - ) + setup_mock_web_api_server(self) + self.client = slack.WebClient(token="xoxb-coverage", base_url=f"http://localhost:8888") for api_method in self.all_api_methods: if api_method.startswith("apps.") or api_method in [ + "oauth.access", + "oauth.v2.access", "oauth.token", "users.setActive", ]: @@ -38,30 +27,7 @@ def setUp(self): self.api_methods_to_call.append(api_method) def tearDown(self): - self.loop.run_until_complete(self.site.stop()) - if not self.loop.is_closed(): - self.loop.close() - - async def mock_server(self): - app = web.Application() - for method_name in self.all_api_methods: - app.router.add_get(f"/{method_name}", self.handler) - app.router.add_post(f"/{method_name}", self.handler) - runner = web.AppRunner(app) - await runner.setup() - self.site = web.TCPSite(runner, "localhost", 8765) - await self.site.start() - - async def handler(self, request): - content_type = request.content_type - assert content_type in [ - "application/json", - "application/x-www-form-urlencoded", - "multipart/form-data", - ] - # This `api_method` is done - self.api_methods_to_call.remove(request.path.replace("/", "")) - return web.json_response({"ok": True}) + cleanup_mock_web_api_server(self) def test_coverage(self): for api_method in self.all_api_methods: @@ -73,274 +39,271 @@ def test_coverage(self): # Run the api calls with required arguments if callable(method): if method_name == "admin_apps_approve": - method(app_id="AID123", request_id="RID123") + self.api_methods_to_call.remove(method(app_id="AID123", request_id="RID123")["method"]) elif method_name == "admin_inviteRequests_approve": - method(invite_request_id="ID123") + self.api_methods_to_call.remove(method(invite_request_id="ID123")["method"]) elif method_name == "admin_inviteRequests_deny": - method(invite_request_id="ID123") + self.api_methods_to_call.remove(method(invite_request_id="ID123")["method"]) elif method_name == "admin_teams_admins_list": - method(team_id="T123") + self.api_methods_to_call.remove(method(team_id="T123")["method"]) elif method_name == "admin_teams_create": - method(team_domain="awesome-team", team_name="Awesome Team") + self.api_methods_to_call.remove( + method(team_domain="awesome-team", team_name="Awesome Team")["method"]) elif method_name == "admin_teams_owners_list": - method(team_id="T123") + self.api_methods_to_call.remove(method(team_id="T123")["method"]) elif method_name == "admin_teams_settings_info": - method(team_id="T123") + self.api_methods_to_call.remove(method(team_id="T123")["method"]) elif method_name == "admin_teams_settings_setDefaultChannels": - method(team_id="T123", channel_ids=["C123", "C234"]) + self.api_methods_to_call.remove(method(team_id="T123", channel_ids=["C123", "C234"])["method"]) elif method_name == "admin_teams_settings_setDescription": - method(team_id="T123", description="Workspace for an awesome team") + self.api_methods_to_call.remove( + method(team_id="T123", description="Workspace for an awesome team")["method"]) elif method_name == "admin_teams_settings_setDiscoverability": - method(team_id="T123", discoverability="invite_only") + self.api_methods_to_call.remove(method(team_id="T123", discoverability="invite_only")["method"]) elif method_name == "admin_teams_settings_setIcon": - method( + self.api_methods_to_call.remove(method( team_id="T123", image_url="https://www.example.com/images/dummy.png", - ) + )["method"]) elif method_name == "admin_teams_settings_setName": - method(team_id="T123", name="Awesome Engineering Team") + self.api_methods_to_call.remove(method(team_id="T123", name="Awesome Engineering Team")["method"]) elif method_name == "admin_users_assign": - method(team_id="T123", user_id="W123") + self.api_methods_to_call.remove(method(team_id="T123", user_id="W123")["method"]) elif method_name == "admin_users_invite": - method( + self.api_methods_to_call.remove(method( team_id="T123", email="test@example.com", channel_ids=["C1A2B3C4D", "C26Z25Y24"], - ) + )["method"]) elif method_name == "admin_users_list": - method(team_id="T123") + self.api_methods_to_call.remove(method(team_id="T123")["method"]) elif method_name == "admin_users_remove": - method(team_id="T123", user_id="W123") + self.api_methods_to_call.remove(method(team_id="T123", user_id="W123")["method"]) elif method_name == "admin_users_setAdmin": - method(team_id="T123", user_id="W123") + self.api_methods_to_call.remove(method(team_id="T123", user_id="W123")["method"]) elif method_name == "admin_users_setExpiration": - method(team_id="T123", user_id="W123", expiration_ts=123) + self.api_methods_to_call.remove(method(team_id="T123", user_id="W123", expiration_ts=123)["method"]) elif method_name == "admin_users_setOwner": - method(team_id="T123", user_id="W123") + self.api_methods_to_call.remove(method(team_id="T123", user_id="W123")["method"]) elif method_name == "admin_users_setRegular": - method(team_id="T123", user_id="W123") + self.api_methods_to_call.remove(method(team_id="T123", user_id="W123")["method"]) elif method_name == "admin_users_session_reset": - method(user_id="W123") + self.api_methods_to_call.remove(method(user_id="W123")["method"]) elif method_name == "chat_delete": - method(channel="C123", ts="123.123") + self.api_methods_to_call.remove(method(channel="C123", ts="123.123")["method"]) elif method_name == "chat_deleteScheduledMessage": - method(channel="C123", scheduled_message_id="123") + self.api_methods_to_call.remove(method(channel="C123", scheduled_message_id="123")["method"]) elif method_name == "chat_getPermalink": - method(channel="C123", message_ts="123.123") + self.api_methods_to_call.remove(method(channel="C123", message_ts="123.123")["method"]) elif method_name == "chat_meMessage": - method(channel="C123", text=":wave: Hi there!") + self.api_methods_to_call.remove(method(channel="C123", text=":wave: Hi there!")["method"]) elif method_name == "chat_postEphemeral": - method(channel="C123", user="U123") + self.api_methods_to_call.remove(method(channel="C123", user="U123")["method"]) elif method_name == "chat_postMessage": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "chat_scheduleMessage": - method(channel="C123", post_at=123, text="Hi") + self.api_methods_to_call.remove(method(channel="C123", post_at=123, text="Hi")["method"]) elif method_name == "chat_unfurl": - method( + self.api_methods_to_call.remove(method( channel="C123", ts="123.123", unfurls={ "https://example.com/": {"text": "Every day is the test."} }, - ) + )["method"]) elif method_name == "chat_update": - method(channel="C123", ts="123.123") + self.api_methods_to_call.remove(method(channel="C123", ts="123.123")["method"]) elif method_name == "conversations_archive": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "conversations_close": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "conversations_create": - method(name="announcements") + self.api_methods_to_call.remove(method(name="announcements")["method"]) elif method_name == "conversations_history": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "conversations_info": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "conversations_invite": - method(channel="C123", users=["U2345678901", "U3456789012"]) + self.api_methods_to_call.remove( + method(channel="C123", users=["U2345678901", "U3456789012"])["method"]) elif method_name == "conversations_join": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "conversations_kick": - method(channel="C123", user="U123") + self.api_methods_to_call.remove(method(channel="C123", user="U123")["method"]) elif method_name == "conversations_leave": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "conversations_members": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "conversations_rename": - method(channel="C123", name="new-name") + self.api_methods_to_call.remove(method(channel="C123", name="new-name")["method"]) elif method_name == "conversations_replies": - method(channel="C123", ts="123.123") + self.api_methods_to_call.remove(method(channel="C123", ts="123.123")["method"]) elif method_name == "conversations_setPurpose": - method(channel="C123", purpose="The purpose") + self.api_methods_to_call.remove(method(channel="C123", purpose="The purpose")["method"]) elif method_name == "conversations_setTopic": - method(channel="C123", topic="The topic") + self.api_methods_to_call.remove(method(channel="C123", topic="The topic")["method"]) elif method_name == "conversations_unarchive": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "dialog_open": - method(dialog={}, trigger_id="123") + self.api_methods_to_call.remove(method(dialog={}, trigger_id="123")["method"]) elif method_name == "dnd_setSnooze": - method(num_minutes=120) + self.api_methods_to_call.remove(method(num_minutes=120)["method"]) elif method_name == "files_comments_delete": - method(file="F123", id="FC123") + self.api_methods_to_call.remove(method(file="F123", id="FC123")["method"]) elif method_name == "files_delete": - method(file="F123") + self.api_methods_to_call.remove(method(file="F123")["method"]) elif method_name == "files_info": - method(file="F123") + self.api_methods_to_call.remove(method(file="F123")["method"]) elif method_name == "files_revokePublicURL": - method(file="F123") + self.api_methods_to_call.remove(method(file="F123")["method"]) elif method_name == "files_sharedPublicURL": - method(file="F123") + self.api_methods_to_call.remove(method(file="F123")["method"]) elif method_name == "files_upload": - method(content="This is the content") + self.api_methods_to_call.remove(method(content="This is the content")["method"]) elif method_name == "files_remote_add": - method( + self.api_methods_to_call.remove(method( external_id="123", external_url="https://www.example.com/remote-files/123", title="File title", - ) + )["method"]) elif method_name == "files_remote_share": - method(channels="C123,G123") + self.api_methods_to_call.remove(method(channels="C123,G123")["method"]) elif method_name == "migration_exchange": - method(users="U123,U234") - elif method_name == "oauth_access": - method = getattr(self.no_token_client, method_name, None) - method(client_id="123.123", client_secret="secret", code="123456") - elif method_name == "oauth_v2_access": - method = getattr(self.no_token_client, method_name, None) - method(client_id="123.123", client_secret="secret", code="123456") + self.api_methods_to_call.remove(method(users="U123,U234")["method"]) elif method_name == "pins_add": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "pins_list": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "pins_remove": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "reactions_add": - method(name="eyes") + self.api_methods_to_call.remove(method(name="eyes")["method"]) elif method_name == "reactions_remove": - method(name="eyes") + self.api_methods_to_call.remove(method(name="eyes")["method"]) elif method_name == "reminders_add": - method(text="The task", time=123) + self.api_methods_to_call.remove(method(text="The task", time=123)["method"]) elif method_name == "reminders_complete": - method(reminder="R123") + self.api_methods_to_call.remove(method(reminder="R123")["method"]) elif method_name == "reminders_delete": - method(reminder="R123") + self.api_methods_to_call.remove(method(reminder="R123")["method"]) elif method_name == "reminders_info": - method(reminder="R123") + self.api_methods_to_call.remove(method(reminder="R123")["method"]) elif method_name == "search_all": - method(query="Slack") + self.api_methods_to_call.remove(method(query="Slack")["method"]) elif method_name == "search_files": - method(query="Slack") + self.api_methods_to_call.remove(method(query="Slack")["method"]) elif method_name == "search_messages": - method(query="Slack") + self.api_methods_to_call.remove(method(query="Slack")["method"]) elif method_name == "usergroups_create": - method(name="Engineering Team") + self.api_methods_to_call.remove(method(name="Engineering Team")["method"]) elif method_name == "usergroups_disable": - method(usergroup="UG123") + self.api_methods_to_call.remove(method(usergroup="UG123")["method"]) elif method_name == "usergroups_enable": - method(usergroup="UG123") + self.api_methods_to_call.remove(method(usergroup="UG123")["method"]) elif method_name == "usergroups_update": - method(usergroup="UG123") + self.api_methods_to_call.remove(method(usergroup="UG123")["method"]) elif method_name == "usergroups_users_list": - method(usergroup="UG123") + self.api_methods_to_call.remove(method(usergroup="UG123")["method"]) elif method_name == "usergroups_users_update": - method(usergroup="UG123", users=["U123", "U234"]) + self.api_methods_to_call.remove(method(usergroup="UG123", users=["U123", "U234"])["method"]) elif method_name == "users_getPresence": - method(user="U123") + self.api_methods_to_call.remove(method(user="U123")["method"]) elif method_name == "users_info": - method(user="U123") + self.api_methods_to_call.remove(method(user="U123")["method"]) elif method_name == "users_lookupByEmail": - method(email="test@example.com") + self.api_methods_to_call.remove(method(email="test@example.com")["method"]) elif method_name == "users_setPhoto": - method(image="README.md") + self.api_methods_to_call.remove(method(image="README.md")["method"]) elif method_name == "users_setPresence": - method(presence="away") + self.api_methods_to_call.remove(method(presence="away")["method"]) elif method_name == "views_open": - method(trigger_id="123123", view={}) + self.api_methods_to_call.remove(method(trigger_id="123123", view={})["method"]) elif method_name == "views_publish": - method(user_id="U123", view={}) + self.api_methods_to_call.remove(method(user_id="U123", view={})["method"]) elif method_name == "views_push": - method(trigger_id="123123", view={}) + self.api_methods_to_call.remove(method(trigger_id="123123", view={})["method"]) elif method_name == "views_update": - method(view_id="V123", view={}) + self.api_methods_to_call.remove(method(view_id="V123", view={})["method"]) elif method_name == "channels_archive": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "channels_create": - method(name="channel-name") + self.api_methods_to_call.remove(method(name="channel-name")["method"]) elif method_name == "channels_history": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "channels_info": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "channels_invite": - method(channel="C123", user="U123") + self.api_methods_to_call.remove(method(channel="C123", user="U123")["method"]) elif method_name == "channels_join": - method(name="channel-name") + self.api_methods_to_call.remove(method(name="channel-name")["method"]) elif method_name == "channels_kick": - method(channel="C123", user="U123") + self.api_methods_to_call.remove(method(channel="C123", user="U123")["method"]) elif method_name == "channels_leave": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "channels_mark": - method(channel="C123", ts="123.123") + self.api_methods_to_call.remove(method(channel="C123", ts="123.123")["method"]) elif method_name == "channels_rename": - method(channel="C123", name="new-name") + self.api_methods_to_call.remove(method(channel="C123", name="new-name")["method"]) elif method_name == "channels_replies": - method(channel="C123", thread_ts="123.123") + self.api_methods_to_call.remove(method(channel="C123", thread_ts="123.123")["method"]) elif method_name == "channels_setPurpose": - method(channel="C123", purpose="The purpose") + self.api_methods_to_call.remove(method(channel="C123", purpose="The purpose")["method"]) elif method_name == "channels_setTopic": - method(channel="C123", topic="The topic") + self.api_methods_to_call.remove(method(channel="C123", topic="The topic")["method"]) elif method_name == "channels_unarchive": - method(channel="C123") + self.api_methods_to_call.remove(method(channel="C123")["method"]) elif method_name == "groups_archive": - method(channel="G123") + self.api_methods_to_call.remove(method(channel="G123")["method"]) elif method_name == "groups_create": - method(name="private-channel-name") + self.api_methods_to_call.remove(method(name="private-channel-name")["method"]) elif method_name == "groups_createChild": - method(channel="G123") + self.api_methods_to_call.remove(method(channel="G123")["method"]) elif method_name == "groups_history": - method(channel="G123") + self.api_methods_to_call.remove(method(channel="G123")["method"]) elif method_name == "groups_info": - method(channel="G123") + self.api_methods_to_call.remove(method(channel="G123")["method"]) elif method_name == "groups_invite": - method(channel="G123", user="U123") + self.api_methods_to_call.remove(method(channel="G123", user="U123")["method"]) elif method_name == "groups_kick": - method(channel="G123", user="U123") + self.api_methods_to_call.remove(method(channel="G123", user="U123")["method"]) elif method_name == "groups_leave": - method(channel="G123") + self.api_methods_to_call.remove(method(channel="G123")["method"]) elif method_name == "groups_mark": - method(channel="C123", ts="123.123") + self.api_methods_to_call.remove(method(channel="C123", ts="123.123")["method"]) elif method_name == "groups_open": - method(channel="G123") + self.api_methods_to_call.remove(method(channel="G123")["method"]) elif method_name == "groups_rename": - method(channel="G123", name="new-name") + self.api_methods_to_call.remove(method(channel="G123", name="new-name")["method"]) elif method_name == "groups_replies": - method(channel="G123", thread_ts="123.123") + self.api_methods_to_call.remove(method(channel="G123", thread_ts="123.123")["method"]) elif method_name == "groups_setPurpose": - method(channel="G123", purpose="The purpose") + self.api_methods_to_call.remove(method(channel="G123", purpose="The purpose")["method"]) elif method_name == "groups_setTopic": - method(channel="G123", topic="The topic") + self.api_methods_to_call.remove(method(channel="G123", topic="The topic")["method"]) elif method_name == "groups_unarchive": - method(channel="G123") + self.api_methods_to_call.remove(method(channel="G123")["method"]) elif method_name == "im_close": - method(channel="D123") + self.api_methods_to_call.remove(method(channel="D123")["method"]) elif method_name == "im_history": - method(channel="D123") + self.api_methods_to_call.remove(method(channel="D123")["method"]) elif method_name == "im_mark": - method(channel="D123", ts="123.123") + self.api_methods_to_call.remove(method(channel="D123", ts="123.123")["method"]) elif method_name == "im_open": - method(user="U123") + self.api_methods_to_call.remove(method(user="U123")["method"]) elif method_name == "im_replies": - method(channel="D123", thread_ts="123.123") + self.api_methods_to_call.remove(method(channel="D123", thread_ts="123.123")["method"]) elif method_name == "mpim_close": - method(channel="D123") + self.api_methods_to_call.remove(method(channel="D123")["method"]) elif method_name == "mpim_history": - method(channel="D123") + self.api_methods_to_call.remove(method(channel="D123")["method"]) elif method_name == "mpim_mark": - method(channel="D123", ts="123.123") + self.api_methods_to_call.remove(method(channel="D123", ts="123.123")["method"]) elif method_name == "mpim_open": - method(users=["U123", "U234"]) + self.api_methods_to_call.remove(method(users=["U123", "U234"])["method"]) elif method_name == "mpim_replies": - method(channel="D123", thread_ts="123.123") + self.api_methods_to_call.remove(method(channel="D123", thread_ts="123.123")["method"]) else: - method(*{}) + self.api_methods_to_call.remove(method(*{})["method"]) else: # Verify if the expected method is supported self.assertTrue(callable(method), f"{method_name} is not supported yet") diff --git a/tests/web/test_web_client_functional.py b/tests/web/test_web_client_functional.py index 5c34decb1..9a3b545cf 100644 --- a/tests/web/test_web_client_functional.py +++ b/tests/web/test_web_client_functional.py @@ -1,38 +1,17 @@ -# Standard Imports import unittest -# ThirdParty Imports -import asyncio -from aiohttp import web - -# Internal Imports import slack +from tests.web.mock_web_api_server import setup_mock_web_api_server, cleanup_mock_web_api_server class TestWebClientFunctional(unittest.TestCase): + def setUp(self): - self.loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.loop) - task = asyncio.ensure_future(self.mock_server(), loop=self.loop) - self.loop.run_until_complete(asyncio.wait_for(task, 0.1)) - self.client = slack.WebClient( - "xoxb-abc-123", base_url="http://localhost:8765", loop=self.loop - ) + setup_mock_web_api_server(self) + self.client = slack.WebClient(token="xoxb-api_test", base_url=f"http://localhost:8888") def tearDown(self): - self.loop.run_until_complete(self.site.stop()) - - async def mock_server(self): - app = web.Application() - app.router.add_post("/api.test", self.handler) - runner = web.AppRunner(app) - await runner.setup() - self.site = web.TCPSite(runner, "localhost", 8765) - await self.site.start() - - async def handler(self, request): - assert request.content_type == "application/json" - return web.json_response({"ok": True}) + cleanup_mock_web_api_server(self) def test_requests_with_use_session_turned_off(self): self.client.use_pooling = False From aa0b45f0730e4ce3e0e1c257d031cf20ece60f0e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 27 Apr 2020 23:16:18 +0900 Subject: [PATCH 02/12] Change marks for fixed integration tests --- integration_tests/rtm/test_issue_530.py | 14 +++++++------- integration_tests/rtm/test_issue_569.py | 12 +++--------- integration_tests/rtm/test_issue_605.py | 3 +-- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/integration_tests/rtm/test_issue_530.py b/integration_tests/rtm/test_issue_530.py index 6789fae90..a4dd29a57 100644 --- a/integration_tests/rtm/test_issue_530.py +++ b/integration_tests/rtm/test_issue_530.py @@ -3,9 +3,7 @@ import logging import unittest -import pytest - -from integration_tests.helpers import async_test, is_not_specified +from integration_tests.helpers import async_test from slack import RTMClient @@ -22,19 +20,19 @@ def tearDown(self): # Reset the decorators by @RTMClient.run_on RTMClient._callbacks = collections.defaultdict(list) - @pytest.mark.skipif(condition=is_not_specified(), reason="still unfixed") def test_issue_530(self): try: rtm_client = RTMClient(token="I am not a token", run_async=False, loop=asyncio.new_event_loop()) rtm_client.start() self.fail("Raising an error here was expected") except Exception as e: - self.assertEqual(str(e), "The server responded with: {'ok': False, 'error': 'invalid_auth'}") + self.assertEqual( + "The request to the Slack API failed.\n" + "The server responded with: {'ok': False, 'error': 'invalid_auth'}", str(e)) finally: if not rtm_client._stopped: rtm_client.stop() - @pytest.mark.skipif(condition=is_not_specified(), reason="still unfixed") @async_test async def test_issue_530_async(self): try: @@ -42,7 +40,9 @@ async def test_issue_530_async(self): await rtm_client.start() self.fail("Raising an error here was expected") except Exception as e: - self.assertEqual(str(e), "The server responded with: {'ok': False, 'error': 'invalid_auth'}") + self.assertEqual( + "The request to the Slack API failed.\n" + "The server responded with: {'ok': False, 'error': 'invalid_auth'}", str(e)) finally: if not rtm_client._stopped: rtm_client.stop() diff --git a/integration_tests/rtm/test_issue_569.py b/integration_tests/rtm/test_issue_569.py index 2a37f92d1..2c98f52c7 100644 --- a/integration_tests/rtm/test_issue_569.py +++ b/integration_tests/rtm/test_issue_569.py @@ -50,16 +50,10 @@ def tearDown(self): if hasattr(self, "rtm_client") and not self.rtm_client._stopped: self.rtm_client.stop() - @pytest.mark.skipif(condition=is_not_specified(), reason="still unfixed") + @pytest.mark.skipif(condition=is_not_specified(), reason="To avoid rate_limited errors") def test_cpu_usage(self): - self.rtm_client = RTMClient( - token=self.bot_token, - run_async=False, - loop=asyncio.new_event_loop()) - self.web_client = WebClient( - token=self.bot_token, - run_async=False, - loop=asyncio.new_event_loop()) + self.rtm_client = RTMClient(token=self.bot_token, run_async=False, loop=asyncio.new_event_loop()) + self.web_client = WebClient(token=self.bot_token, run_async=False) self.call_count = 0 TestRTMClient.cpu_usage = 0 diff --git a/integration_tests/rtm/test_issue_605.py b/integration_tests/rtm/test_issue_605.py index 2fadc84a8..ccb218301 100644 --- a/integration_tests/rtm/test_issue_605.py +++ b/integration_tests/rtm/test_issue_605.py @@ -31,7 +31,7 @@ def tearDown(self): # Reset the decorators by @RTMClient.run_on RTMClient._callbacks = collections.defaultdict(list) - @pytest.mark.skipif(condition=is_not_specified(), reason="still unfixed") + @pytest.mark.skipif(condition=is_not_specified(), reason="To avoid rate_limited errors") def test_issue_605(self): self.text = "This message was sent to verify issue #605" self.called = False @@ -56,7 +56,6 @@ def connect(): self.web_client = WebClient( token=self.bot_token, run_async=False, - loop=asyncio.new_event_loop(), # TODO: this doesn't work without this ) new_message = self.web_client.chat_postMessage(channel=self.channel_id, text=self.text) self.assertFalse("error" in new_message) From e92442d5953e6ad1d6a20b1ce50e4fd8bf877f5a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 27 Apr 2020 23:50:44 +0900 Subject: [PATCH 03/12] Change marks for fixed integration tests --- integration_tests/rtm/test_issue_631.py | 12 +++++------- integration_tests/samples/issues/issue_497.py | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/integration_tests/rtm/test_issue_631.py b/integration_tests/rtm/test_issue_631.py index 1d14df0cd..02b4d3868 100644 --- a/integration_tests/rtm/test_issue_631.py +++ b/integration_tests/rtm/test_issue_631.py @@ -35,15 +35,15 @@ def tearDown(self): if hasattr(self, "rtm_client") and not self.rtm_client._stopped: self.rtm_client.stop() - @pytest.mark.skipif(condition=is_not_specified(), reason="this is just for reference") + @pytest.mark.skipif(condition=is_not_specified(), reason="to avoid rate_limited errors") def test_issue_631_sharing_event_loop(self): self.success = None self.text = "This message was sent to verify issue #631" self.rtm_client = RTMClient( token=self.bot_token, - run_async=False, # even though run_async=False, handlers for RTM events can be a coroutine - loop=asyncio.new_event_loop(), # TODO: remove this + run_async=False, + loop=asyncio.new_event_loop(), # TODO: this doesn't work without this ) # @RTMClient.run_on(event="message") @@ -72,8 +72,7 @@ def test_issue_631_sharing_event_loop(self): # Solution (1) for #631 @RTMClient.run_on(event="message") - # even though run_async=False, handlers for RTM events can be a coroutine - async def send_reply(**payload): + def send_reply(**payload): self.logger.debug(payload) data = payload['data'] web_client = payload['web_client'] @@ -82,7 +81,7 @@ async def send_reply(**payload): if "text" in data and self.text in data["text"]: channel_id = data['channel'] thread_ts = data['ts'] - self.success = await web_client.chat_postMessage( + self.success = web_client.chat_postMessage( channel=channel_id, text="Thanks!", thread_ts=thread_ts @@ -106,7 +105,6 @@ def connect(): self.web_client = WebClient( token=self.bot_token, run_async=False, - loop=asyncio.new_event_loop(), # TODO: this doesn't work without this ) new_message = self.web_client.chat_postMessage(channel=self.channel_id, text=self.text) self.assertFalse("error" in new_message) diff --git a/integration_tests/samples/issues/issue_497.py b/integration_tests/samples/issues/issue_497.py index 6f9abcb0a..5ddb8c7c4 100644 --- a/integration_tests/samples/issues/issue_497.py +++ b/integration_tests/samples/issues/issue_497.py @@ -34,7 +34,7 @@ ) -# This doesn't work +# Fixed in 2.6.0: This doesn't work @app.route("/sync/singleton", methods=["GET"]) def singleton(): try: From fb21a255a6d6a54ce595290386dc2307c48662d0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 27 Apr 2020 23:51:12 +0900 Subject: [PATCH 04/12] Revise WebClient/RTMClient internals for run_async=False * #530 Fixed by changing _execute_in_thread to be a coroutine * #569 Resolved by removing a blocking loop (while future.running()) * #645 WebClient(run_async=False) no longer depends on asyncio by default * #633 WebClient(run_async=False) doesn't internally depend on aiohttp * #631 When run_async=True, RTM listner can be a normal function and WebClient is free from the event loop * #630 WebClient no longer depends on aiohttp when run_async=False * #497 Fixed when run_async=False / can be closed as we don't support run_async=True for this use case (in Flask) --- slack/rtm/client.py | 75 +++++++---- slack/web/__init__.py | 20 +++ slack/web/base_client.py | 89 ++++++++----- slack/web/slack_response.py | 33 +++-- slack/web/urllib_client.py | 245 ++++++++++++++++++++++++++++++++++++ 5 files changed, 395 insertions(+), 67 deletions(-) create mode 100644 slack/web/urllib_client.py diff --git a/slack/rtm/client.py b/slack/rtm/client.py index 8fef13671..900c7a0b0 100644 --- a/slack/rtm/client.py +++ b/slack/rtm/client.py @@ -5,9 +5,9 @@ import logging import random import collections -import concurrent import inspect import signal +from concurrent.futures.thread import ThreadPoolExecutor from typing import Optional, Callable, DefaultDict from ssl import SSLContext from threading import current_thread, main_thread @@ -107,6 +107,8 @@ def __init__( *, token: str, run_async: Optional[bool] = False, + # will be used only when run_async=False + run_sync_thread_pool_size: int = 3, auto_reconnect: Optional[bool] = True, ssl: Optional[SSLContext] = None, proxy: Optional[str] = None, @@ -119,6 +121,9 @@ def __init__( ): self.token = token.strip() self.run_async = run_async + self.thread_pool_executor = ThreadPoolExecutor( + max_workers=run_sync_thread_pool_size + ) self.auto_reconnect = auto_reconnect self.ssl = ssl self.proxy = proxy @@ -135,6 +140,16 @@ def __init__( self._last_message_id = 0 self._connection_attempts = 0 self._stopped = False + self._web_client = WebClient( + token=self.token, + base_url=self.base_url, + ssl=self.ssl, + proxy=self.proxy, + run_async=self.run_async, + loop=self._event_loop, + session=self._session, + headers=self.headers, + ) @staticmethod def run_on(*, event: str): @@ -195,8 +210,8 @@ def start(self) -> asyncio.Future: if self.run_async: return future - - return self._event_loop.run_until_complete(future) + else: + return self._event_loop.run_until_complete(future) def stop(self): """Closes the websocket connection and ensures it won't reconnect.""" @@ -351,7 +366,6 @@ async def _connect_and_read(self): client_err.SlackApiError, # TODO: Catch websocket exceptions thrown by aiohttp. ) as exception: - self._logger.debug(str(exception)) await self._dispatch_event(event="error", data=exception) if self.auto_reconnect and not self._stopped: await self._wait_exponentially(exception) @@ -433,12 +447,14 @@ async def _dispatch_event(self, event, data=None): # close/error callbacks. break - if inspect.iscoroutinefunction(callback): + if self.run_async or inspect.iscoroutinefunction(callback): await callback( rtm_client=self, web_client=self._web_client, data=data ) else: - self._execute_in_thread(callback, data) + await self._execute_in_thread( + callback=callback, web_client=self._web_client, data=data + ) except Exception as err: name = callback.__name__ module = callback.__module__ @@ -446,24 +462,12 @@ async def _dispatch_event(self, event, data=None): self._logger.error(msg) raise - def _execute_in_thread(self, callback, data): + async def _execute_in_thread(self, callback, web_client, data): """Execute the callback in another thread. Wait for and return the results.""" - web_client = WebClient( - token=self.token, - base_url=self.base_url, - ssl=self.ssl, - proxy=self.proxy, - headers=self.headers, + future = self.thread_pool_executor.submit( + callback, rtm_client=self, web_client=web_client, data=data ) - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit( - callback, rtm_client=self, web_client=web_client, data=data - ) - - while future.running(): - pass - - future.result() + return future.result() async def _retrieve_websocket_info(self): """Retrieves the WebSocket info from Slack. @@ -498,10 +502,18 @@ async def _retrieve_websocket_info(self): headers=self.headers, ) self._logger.debug("Retrieving websocket info.") - if self.connect_method in ["rtm.start", "rtm_start"]: - resp = await self._web_client.rtm_start() + use_rtm_start = self.connect_method in ["rtm.start", "rtm_start"] + if self.run_async: + if use_rtm_start: + resp = await self._web_client.rtm_start() + else: + resp = await self._web_client.rtm_connect() else: - resp = await self._web_client.rtm_connect() + if use_rtm_start: + resp = self._web_client.rtm_start() + else: + resp = self._web_client.rtm_connect() + url = resp.get("url") if url is None: msg = "Unable to retrieve RTM URL from Slack." @@ -513,7 +525,7 @@ async def _wait_exponentially(self, exception, max_wait_time=300): Calculate the number of seconds to wait and then add a random number of milliseconds to avoid coincidental - synchronized client retries. Wait up to the maximium amount + synchronized client retries. Wait up to the maximum amount of wait time specified via 'max_wait_time'. However, if Slack returned how long to wait use that. """ @@ -521,7 +533,16 @@ async def _wait_exponentially(self, exception, max_wait_time=300): (2 ** self._connection_attempts) + random.random(), max_wait_time ) try: - wait_time = exception.response["headers"]["Retry-After"] + headers = ( + exception.response["headers"] + if "headers" in exception.response + else None + ) + if headers and "Retry-After" in headers: + wait_time = headers["Retry-After"] + else: + # an error returned due to other unrecoverable reasons + raise exception except (KeyError, AttributeError): pass self._logger.debug("Waiting %s seconds before reconnecting.", wait_time) diff --git a/slack/web/__init__.py b/slack/web/__init__.py index e69de29bb..02dc7b0ac 100644 --- a/slack/web/__init__.py +++ b/slack/web/__init__.py @@ -0,0 +1,20 @@ +import platform +import sys + +import slack.version as ver + + +def get_user_agent(): + """Construct the user-agent header with the package info, + Python version and OS version. + + Returns: + The user agent string. + e.g. 'Python/3.6.7 slackclient/2.0.0 Darwin/17.7.0' + """ + # __name__ returns all classes, we only want the client + client = "{0}/{1}".format("slackclient", ver.__version__) + python_version = "Python/{v.major}.{v.minor}.{v.micro}".format(v=sys.version_info) + system_info = "{0}/{1}".format(platform.system(), platform.release()) + user_agent_string = " ".join([python_version, client, system_info]) + return user_agent_string diff --git a/slack/web/base_client.py b/slack/web/base_client.py index 748407951..1e7f1e831 100644 --- a/slack/web/base_client.py +++ b/slack/web/base_client.py @@ -1,9 +1,8 @@ """A Python module for interacting with Slack's Web API.""" # Standard Imports +import json from urllib.parse import urljoin -import platform -import sys import logging import asyncio from typing import Optional, Union @@ -15,9 +14,10 @@ from aiohttp import FormData, BasicAuth # Internal Imports +from slack.web import get_user_agent from slack.web.slack_response import SlackResponse -import slack.version as ver import slack.errors as err +from slack.web.urllib_client import UrllibWebClient class BaseClient: @@ -32,6 +32,7 @@ def __init__( ssl=None, proxy=None, run_async=False, + use_sync_aiohttp=False, session=None, headers: Optional[dict] = None, ): @@ -41,11 +42,16 @@ def __init__( self.ssl = ssl self.proxy = proxy self.run_async = run_async + self.use_sync_aiohttp = use_sync_aiohttp self.session = session self.headers = headers or {} self._logger = logging.getLogger(__name__) self._event_loop = loop + self.urllib_client = UrllibWebClient( + token=self.token, default_headers=self.headers, web_client=self, + ) + def _get_event_loop(self): """Retrieves the event loop or creates a new one.""" try: @@ -58,7 +64,7 @@ def _get_event_loop(self): def _get_headers( self, has_json: bool, has_files: bool, request_specific_headers: Optional[dict] ): - """Contructs the headers need for a request. + """Constructs the headers need for a request. Args: has_json (bool): Whether or not the request has json. has_files (bool): Whether or not the request has files. @@ -73,7 +79,7 @@ def _get_headers( } """ final_headers = { - "User-Agent": self._get_user_agent(), + "User-Agent": get_user_agent(), "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", } @@ -115,7 +121,7 @@ def api_call( e.g. 'chat.postMessage' http_verb (str): HTTP Verb. e.g. 'POST' files (dict): Files to multipart upload. - e.g. {imageORfile: file_objectORfile_path} + e.g. {image OR file: file_object OR file_path} data: The body to attach to the request. If a dictionary is provided, form-encoding will take place. e.g. {'key1': 'value1', 'key2': 'value2'} @@ -160,18 +166,21 @@ def api_call( "auth": auth, } - if self._event_loop is None: - self._event_loop = self._get_event_loop() - - future = asyncio.ensure_future( - self._send(http_verb=http_verb, api_url=api_url, req_args=req_args), - loop=self._event_loop, - ) - - if self.run_async: - return future + if self.run_async or self.use_sync_aiohttp: + if self._event_loop is None: + self._event_loop = self._get_event_loop() - return self._event_loop.run_until_complete(future) + future = asyncio.ensure_future( + self._send(http_verb=http_verb, api_url=api_url, req_args=req_args), + loop=self._event_loop, + ) + if self.run_async: + return future + elif self.use_sync_aiohttp: + # Using this is no longer recommended - just keep this for backward-compatibility + return self._event_loop.run_until_complete(future) + else: + return self._sync_send(api_url=api_url, req_args=req_args) def _get_url(self, api_method): """Joins the base Slack URL and an API method to form an absolute URL. @@ -225,6 +234,7 @@ async def _send(self, http_verb, api_url, req_args): "http_verb": http_verb, "api_url": api_url, "req_args": req_args, + "use_sync_aiohttp": self.use_sync_aiohttp, } return SlackResponse(**{**data, **res}).validate() @@ -258,23 +268,38 @@ async def _request(self, *, http_verb, api_url, req_args): await session.close() return response - @staticmethod - def _get_user_agent(): - """Construct the user-agent header with the package info, - Python version and OS version. + def _sync_send(self, api_url, req_args): + params = req_args["params"] if "params" in req_args else None + data = req_args["data"] if "data" in req_args else None + files = req_args["files"] if "files" in req_args else None + json = req_args["json"] if "files" in req_args else None + headers = req_args["headers"] if "headers" in req_args else None + token = params.get("token") if params and "token" in params else None + body_params = {} + if params: + body_params.update(params) + if data: + body_params.update(data) + + return self.urllib_client.api_call( + token=token, + url=api_url, + query_params={}, + body_params=body_params, + files=files, + json_body=json, + additional_headers=headers, + ) - Returns: - The user agent string. - e.g. 'Python/3.6.7 slackclient/2.0.0 Darwin/17.7.0' - """ - # __name__ returns all classes, we only want the client - client = "{0}/{1}".format("slackclient", ver.__version__) - python_version = "Python/{v.major}.{v.minor}.{v.micro}".format( - v=sys.version_info + def _sync_request(self, api_url, req_args): + response, response_body = self.urllib_client._perform_http_request( + url=api_url, args=req_args, ) - system_info = "{0}/{1}".format(platform.system(), platform.release()) - user_agent_string = " ".join([python_version, client, system_info]) - return user_agent_string + return { + "status_code": int(response.status), + "headers": dict(response.headers), + "data": json.loads(response_body), + } @staticmethod def validate_slack_signature( diff --git a/slack/web/slack_response.py b/slack/web/slack_response.py index 8a04b693d..1fcefb78b 100644 --- a/slack/web/slack_response.py +++ b/slack/web/slack_response.py @@ -1,8 +1,9 @@ """A Python module for interacting and consuming responses from Slack.""" +import asyncio + # Standard Imports import logging -import asyncio # Internal Imports import slack.errors as e @@ -63,6 +64,7 @@ def __init__( data: dict, headers: dict, status_code: int, + use_sync_aiohttp: bool = True, # True for backward-compatibility ): self.http_verb = http_verb self.api_url = api_url @@ -72,6 +74,7 @@ def __init__( self.status_code = status_code self._initial_data = data self._client = client + self._use_sync_aiohttp = use_sync_aiohttp self._logger = logging.getLogger(__name__) def __str__(self): @@ -132,13 +135,27 @@ def __next__(self): {"cursor": self.data["response_metadata"]["next_cursor"]} ) - response = asyncio.get_event_loop().run_until_complete( - self._client._request( - http_verb=self.http_verb, - api_url=self.api_url, - req_args=self.req_args, + if self._use_sync_aiohttp: + # We no longer recommend going with this way + response = asyncio.get_event_loop().run_until_complete( + self._client._request( + http_verb=self.http_verb, + api_url=self.api_url, + req_args=self.req_args, + ) ) - ) + else: + if self._client is None: + # NOTE: It's not possible to support this due to circular import issues. + msg = ( + "Directly using UrllibWebClient doesn't support response pagination as iteration. " + "Use WebClient with run_async=False and use_sync_aiohttp=False." + ) + raise e.SlackRequestError(msg) + response = self._client._sync_request( + api_url=self.api_url, req_args=self.req_args + ) + self.data = response["data"] self.headers = response["headers"] self.status_code = response["status_code"] @@ -169,7 +186,7 @@ def validate(self): Raises: SlackApiError: The request to the Slack API failed. """ - if self.status_code == 200 and self.data.get("ok", False): + if self.status_code == 200 and self.data and self.data.get("ok", False): self._logger.debug("Received the following response: %s", self.data) return self msg = "The request to the Slack API failed." diff --git a/slack/web/urllib_client.py b/slack/web/urllib_client.py new file mode 100644 index 000000000..13146d5e3 --- /dev/null +++ b/slack/web/urllib_client.py @@ -0,0 +1,245 @@ +import copy +import io +import json +import logging +import mimetypes +import uuid +from http.client import HTTPResponse +from typing import BinaryIO, Dict, List, Union +from urllib.error import HTTPError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from slack.web import get_user_agent +from slack.web.slack_response import SlackResponse + + +class HttpErrorResponse(object): + pass + + +class UrllibWebClient: + logger = logging.getLogger(__name__) + + def __init__( + self, + *, + token: str = None, + default_headers: Dict[str, str] = dict(), + # Not type here to avoid ImportError: cannot import name 'WebClient' from partially initialized module + # 'slack.web.client' (most likely due to a circular import + web_client=None, + ): + """urllib-based API client. + + :param token: Slack API Token (either bot token or user token) + :param default_headers: request headers to add to all requests + :param web_client: WebClient instance for pagination + """ + self.token = token + self.default_headers = default_headers + self.web_client = web_client + + def api_call( + self, + *, + token: str = None, + url: str, + query_params: Dict[str, str] = dict(), + json_body: Dict = dict(), + body_params: Dict[str, str] = dict(), + files: Dict[str, io.BytesIO] = dict(), + additional_headers: Dict[str, str] = dict(), + ) -> SlackResponse: + """Performs a Slack API request and returns the result. + + :param token: Slack API Token (either bot token or user token) + :param url: a complete URL (e.g., https://www.slack.com/api/chat.postMessage) + :param query_params: query string + :param json_body: json data structure (it's still a dict at this point), + if you give this argument, body_params and files will be skipped + :param body_params: form params + :param files: files to upload + :param additional_headers: request headers to append + :return: API response + """ + files_to_close: List[BinaryIO] = [] + try: + if self.logger.level <= logging.DEBUG: + self.logger.debug( + f"Slack API Request - url: {url}, " + f"query_params: {query_params}, " + f"json_body: {json_body}, " + f"body_params: {body_params}, " + f"files: {files}, " + f"additional_headers: {additional_headers}" + ) + + request_data = {} + if files: + if body_params: + for k, v in body_params.items(): + request_data.update({k: v}) + + for k, v in files.items(): + if isinstance(v, str): + f: BinaryIO = open(v.encode("ascii", "ignore"), "rb") + files_to_close.append(f) + request_data.update({k: f}) + else: + request_data.update({k: v}) + + request_headers = self._build_request_headers( + token=token or self.token, + has_json=json is not None, + has_files=files is not None, + additional_headers=additional_headers, + ) + request_args = { + "headers": request_headers, + "data": request_data, + "params": body_params, + "files": files, + "json": json_body, + } + if query_params: + q = urlencode(query_params) + url = f"{url}&{q}" if "?" in url else f"{url}?{q}" + + response, response_body = self._perform_http_request( + url=url, args=request_args + ) + if response_body: + response_body_data: dict = json.loads(response_body) + else: + response_body_data: dict = None + + if query_params: + all_params = copy.copy(body_params) + all_params.update(query_params) + else: + all_params = body_params + request_args["params"] = all_params # for backward-compatibility + return SlackResponse( + client=self.web_client, + http_verb="POST", # you can use POST method for all the Web APIs + api_url=url, + req_args=request_args, + data=response_body_data, + headers=dict(response.headers), + status_code=response.status, + use_sync_aiohttp=False, + ).validate() + finally: + for f in files_to_close: + if not f.closed: + f.close() + + def _perform_http_request( + self, *, url: str, args: Dict[str, Dict[str, any]] + ) -> (Union[HTTPResponse, HttpErrorResponse], str): + """Performs an HTTP request and parses the response. + + :param url: a complete URL (e.g., https://www.slack.com/api/chat.postMessage) + :param args: args has "headers", "data", "params", and "json" + "headers": Dict[str, str] + "data": Dict[str, any] + "params": Dict[str, str], + "json": Dict[str, any], + :return: a tuple (HTTP response and its body) + """ + headers = args["headers"] + if args["json"]: + body = json.dumps(args["json"]) + headers["Content-Type"] = "application/json;charset=utf-8" + elif args["data"]: + boundary = f"--------------{uuid.uuid4()}" + sep_boundary = b"\r\n--" + boundary.encode("ascii") + end_boundary = sep_boundary + b"--\r\n" + body = io.BytesIO() + data = args["data"] + for key, value in data.items(): + readable = getattr(value, "readable", None) + if readable and value.readable(): + filename = "Uploaded file" + name_attr = getattr(value, "name", None) + if name_attr: + filename = ( + name_attr.decode("utf-8") + if isinstance(name_attr, bytes) + else name_attr + ) + if "filename" in data: + filename = data["filename"] + mimetype = ( + mimetypes.guess_type(filename)[0] or "application/octet-stream" + ) + title = ( + f'\r\nContent-Disposition: form-data; name="{key}"; filename="{filename}"\r\n' + + f"Content-Type: {mimetype}\r\n" + ) + value = value.read() + else: + title = f'\r\nContent-Disposition: form-data; name="{key}"\r\n' + value = str(value).encode("utf-8") + body.write(sep_boundary) + body.write(title.encode("utf-8")) + body.write(b"\r\n") + body.write(value) + + body.write(end_boundary) + body = body.getvalue() + headers["Content-Type"] = f"multipart/form-data; boundary={boundary}" + headers["Content-Length"] = len(body) + elif args["params"]: + body = urlencode(args["params"]) + headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8" + else: + body = None + + if isinstance(body, str): + body = body.encode("utf-8") + + try: + # NOTE: Intentionally ignore the `http_verb` here + # Slack APIs accepts any API method requests with POST methods + req = Request(method="POST", url=url, data=body, headers=headers) + resp: HTTPResponse = urlopen(req) + charset = resp.headers.get_content_charset() + body: str = resp.read().decode(charset) # read the response body here + return resp, body + except HTTPError as e: + resp: HttpErrorResponse = HttpErrorResponse() + resp.status = e.code + resp.reason = e.reason + resp.headers = e.headers + charset = resp.headers.get_content_charset() + body: str = e.read().decode(charset) # read the response body here + if e.code == 429: + # for compatibility with aiohttp + resp.headers["Retry-After"] = resp.headers["retry-after"] + + return resp, body + + except Exception as err: + self.logger.error(f"Failed to send a request to Slack API server: {err}") + raise err + + def _build_request_headers( + self, token: str, has_json: bool, has_files: bool, additional_headers: dict, + ): + headers = { + "User-Agent": get_user_agent(), + "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", + } + headers.update(self.default_headers) + if token: + headers.update({"Authorization": "Bearer {}".format(token)}) + if additional_headers: + headers.update(additional_headers) + if has_json: + headers.update({"Content-Type": "application/json;charset=utf-8"}) + if has_files: + # will be set afterwards + headers.pop("Content-Type", None) + return headers From 5cde3e26e9b8415e62550241f8d60dc7022e6bbb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Apr 2020 15:17:24 +0900 Subject: [PATCH 05/12] Fix #650 Deprecation warnings to channels/groups/mpim/im API method calls --- slack/web/__init__.py | 23 +++++++++++++++++++++++ slack/web/base_client.py | 5 ++++- slack/web/client.py | 4 +++- slack/web/urllib_client.py | 12 +++++++++++- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/slack/web/__init__.py b/slack/web/__init__.py index 02dc7b0ac..f580e5a34 100644 --- a/slack/web/__init__.py +++ b/slack/web/__init__.py @@ -1,5 +1,6 @@ import platform import sys +import warnings import slack.version as ver @@ -18,3 +19,25 @@ def get_user_agent(): system_info = "{0}/{1}".format(platform.system(), platform.release()) user_agent_string = " ".join([python_version, client, system_info]) return user_agent_string + + +# https://api.slack.com/changelog/2020-01-deprecating-antecedents-to-the-conversations-api +deprecated_method_prefixes_2020_01 = ["channels.", "groups.", "im.", "mpim."] + + +def show_2020_01_deprecation(method_name: str): + if not method_name: + return + + matched_prefixes = [ + prefix + for prefix in deprecated_method_prefixes_2020_01 + if method_name.startswith(prefix) + ] + if len(matched_prefixes) > 0: + message = ( + f"{method_name} is deprecated. Please use the Conversations API instead. " + f"For more info, go to " + f"https://api.slack.com/changelog/2020-01-deprecating-antecedents-to-the-conversations-api" + ) + warnings.warn(message) diff --git a/slack/web/base_client.py b/slack/web/base_client.py index 1e7f1e831..29849b2e6 100644 --- a/slack/web/base_client.py +++ b/slack/web/base_client.py @@ -14,7 +14,7 @@ from aiohttp import FormData, BasicAuth # Internal Imports -from slack.web import get_user_agent +from slack.web import get_user_agent, show_2020_01_deprecation from slack.web.slack_response import SlackResponse import slack.errors as err from slack.web.urllib_client import UrllibWebClient @@ -167,6 +167,9 @@ def api_call( } if self.run_async or self.use_sync_aiohttp: + # NOTE: For sync mode client, show_2020_01_deprecation(str) is called inside UrllibClient + show_2020_01_deprecation(api_method) + if self._event_loop is None: self._event_loop = self._get_event_loop() diff --git a/slack/web/client.py b/slack/web/client.py index 05930648f..41eb4cff5 100644 --- a/slack/web/client.py +++ b/slack/web/client.py @@ -323,7 +323,9 @@ def admin_users_invite( channel_ids (list): A list of channel_ids for this user to join. At least one channel is required. e.g. ['C1A2B3C4D', 'C26Z25Y24'] """ - kwargs.update({"team_id": team_id, "email": email, "channel_ids": ",".join(channel_ids)}) + kwargs.update( + {"team_id": team_id, "email": email, "channel_ids": ",".join(channel_ids)} + ) return self.api_call("admin.users.invite", json=kwargs) def admin_users_list( diff --git a/slack/web/urllib_client.py b/slack/web/urllib_client.py index 13146d5e3..c49a395b9 100644 --- a/slack/web/urllib_client.py +++ b/slack/web/urllib_client.py @@ -10,7 +10,7 @@ from urllib.parse import urlencode from urllib.request import Request, urlopen -from slack.web import get_user_agent +from slack.web import get_user_agent, show_2020_01_deprecation from slack.web.slack_response import SlackResponse @@ -63,6 +63,9 @@ def api_call( :param additional_headers: request headers to append :return: API response """ + + show_2020_01_deprecation(self._to_api_method(url)) + files_to_close: List[BinaryIO] = [] try: if self.logger.level <= logging.DEBUG: @@ -243,3 +246,10 @@ def _build_request_headers( # will be set afterwards headers.pop("Content-Type", None) return headers + + def _to_api_method(self, url: str): + if url: + elements = url.split("/") + if elements and len(elements) > 0: + return elements[len(elements) - 1].split("?", 1)[0] + return None From 6b562d4bef1c55d69ac9b3d45379166e9dfda7e6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Apr 2020 16:51:23 +0900 Subject: [PATCH 06/12] Fix #560 Allow boolean kwargs --- integration_tests/web/test_issue_560.py | 33 +++----- slack/web/__init__.py | 32 ++++++++ slack/web/base_client.py | 6 +- slack/web/urllib_client.py | 6 +- .../data/web_response_conversations_list.json | 82 +++++++++++++++++++ tests/web/test_web_client.py | 18 ++++ 6 files changed, 152 insertions(+), 25 deletions(-) create mode 100644 tests/data/web_response_conversations_list.json diff --git a/integration_tests/web/test_issue_560.py b/integration_tests/web/test_issue_560.py index e33e18302..e67170989 100644 --- a/integration_tests/web/test_issue_560.py +++ b/integration_tests/web/test_issue_560.py @@ -33,35 +33,22 @@ def test_issue_560_success(self): response = client.conversations_list(exclude_archived="true") self.assertIsNotNone(response) - @pytest.mark.skipif(condition=is_not_specified(), reason="still unfixed") + @async_test + async def test_issue_560_success_async(self): + client = self.sync_client + response = client.conversations_list(exclude_archived=1) + self.assertIsNotNone(response) + + response = client.conversations_list(exclude_archived="true") + self.assertIsNotNone(response) + def test_issue_560_failure(self): client = self.sync_client response = client.conversations_list(exclude_archived=True) self.assertIsNotNone(response) - @pytest.mark.skipif(condition=is_not_specified(), reason="still unfixed") @async_test async def test_issue_560_failure_async(self): client = self.async_client response = await client.conversations_list(exclude_archived=True) - self.assertIsNotNone(response) - - # _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - # - # v = True - # - # @staticmethod - # def _query_var(v): - # if isinstance(v, str): - # return v - # if type(v) is int: # no subclasses like bool - # return str(v) - # > raise TypeError( - # "Invalid variable type: value " - # "should be str or int, got {!r} " - # "of type {}".format(v, type(v)) - # ) - # E TypeError: Invalid variable type: value should be str or int, got True of type - # - # path-to-python/site-packages/yarl/__init__.py:824: TypeError - # -------------------------------------------------------------------------------------------------- Captured log call -------------------------------------------------------------------------------------------------- + self.assertIsNotNone(response) \ No newline at end of file diff --git a/slack/web/__init__.py b/slack/web/__init__.py index f580e5a34..9d7fb1de8 100644 --- a/slack/web/__init__.py +++ b/slack/web/__init__.py @@ -1,10 +1,14 @@ import platform import sys import warnings +from typing import Dict import slack.version as ver +# --------------------------------------- + + def get_user_agent(): """Construct the user-agent header with the package info, Python version and OS version. @@ -21,6 +25,34 @@ def get_user_agent(): return user_agent_string +# --------------------------------------- + + +def _to_0_or_1_if_bool(v: any) -> str: + if isinstance(v, bool): + return "1" if v else "0" + else: + return v + + +def convert_bool_to_0_or_1(params: Dict[str, any]) -> Dict[str, any]: + """Converts all bool values in dict to "0" or "1". + + Slack APIs safely accept "0"/"1" as boolean values. + Using True/False (bool in Python) doesn't work with aiohttp. + This method converts only the bool values in top-level of a given dict. + + :param params: params as a dict + :return: return modified dict + """ + if params: + return {k: _to_0_or_1_if_bool(v) for k, v in params.items()} + else: + return None + + +# --------------------------------------- + # https://api.slack.com/changelog/2020-01-deprecating-antecedents-to-the-conversations-api deprecated_method_prefixes_2020_01 = ["channels.", "groups.", "im.", "mpim."] diff --git a/slack/web/base_client.py b/slack/web/base_client.py index 29849b2e6..fc2a705cf 100644 --- a/slack/web/base_client.py +++ b/slack/web/base_client.py @@ -14,7 +14,7 @@ from aiohttp import FormData, BasicAuth # Internal Imports -from slack.web import get_user_agent, show_2020_01_deprecation +from slack.web import get_user_agent, show_2020_01_deprecation, convert_bool_to_0_or_1 from slack.web.slack_response import SlackResponse import slack.errors as err from slack.web.urllib_client import UrllibWebClient @@ -225,6 +225,10 @@ async def _send(self, http_verb, api_url, req_args): else: req_args["data"].update({k: v}) + if "params" in req_args: + # True/False -> "1"/"0" + req_args["params"] = convert_bool_to_0_or_1(req_args["params"]) + res = await self._request( http_verb=http_verb, api_url=api_url, req_args=req_args ) diff --git a/slack/web/urllib_client.py b/slack/web/urllib_client.py index c49a395b9..f7882a314 100644 --- a/slack/web/urllib_client.py +++ b/slack/web/urllib_client.py @@ -10,7 +10,7 @@ from urllib.parse import urlencode from urllib.request import Request, urlopen -from slack.web import get_user_agent, show_2020_01_deprecation +from slack.web import get_user_agent, show_2020_01_deprecation, convert_bool_to_0_or_1 from slack.web.slack_response import SlackResponse @@ -68,6 +68,10 @@ def api_call( files_to_close: List[BinaryIO] = [] try: + # True/False -> "1"/"0" + query_params = convert_bool_to_0_or_1(query_params) + body_params = convert_bool_to_0_or_1(body_params) + if self.logger.level <= logging.DEBUG: self.logger.debug( f"Slack API Request - url: {url}, " diff --git a/tests/data/web_response_conversations_list.json b/tests/data/web_response_conversations_list.json new file mode 100644 index 000000000..e909a1dd1 --- /dev/null +++ b/tests/data/web_response_conversations_list.json @@ -0,0 +1,82 @@ +{ + "ok": true, + "channels": [ + { + "id": "C111", + "name": "general", + "is_channel": true, + "is_group": false, + "is_im": false, + "created": 1421924831, + "is_archived": false, + "is_general": true, + "unlinked": 0, + "name_normalized": "general", + "is_shared": false, + "creator": "U111", + "is_ext_shared": false, + "is_org_shared": false, + "shared_team_ids": [ + "T111" + ], + "pending_shared": [], + "pending_connected_team_ids": [], + "is_pending_ext_shared": false, + "is_member": true, + "is_private": false, + "is_mpim": false, + "topic": { + "value": "", + "creator": "", + "last_set": 0 + }, + "purpose": { + "value": "This channel is for team-wide communication and announcements. All team members are in this channel.", + "creator": "", + "last_set": 0 + }, + "previous_names": [], + "num_members": 8 + }, + { + "id": "C222", + "name": "random", + "is_channel": true, + "is_group": false, + "is_im": false, + "created": 1421924831, + "is_archived": false, + "is_general": false, + "unlinked": 0, + "name_normalized": "random", + "is_shared": false, + "creator": "U111", + "is_ext_shared": false, + "is_org_shared": false, + "shared_team_ids": [ + "T111" + ], + "pending_shared": [], + "pending_connected_team_ids": [], + "is_pending_ext_shared": false, + "is_member": true, + "is_private": false, + "is_mpim": false, + "topic": { + "value": "", + "creator": "", + "last_set": 0 + }, + "purpose": { + "value": "A place for non-work banter, links, articles of interest, humor or anything else which you\u0027d like concentrated in some place other than work-related channels.", + "creator": "", + "last_set": 0 + }, + "previous_names": [], + "num_members": 10 + } + ], + "response_metadata": { + "next_cursor": "" + } +} \ No newline at end of file diff --git a/tests/web/test_web_client.py b/tests/web/test_web_client.py index 255acffd0..52af1aaa3 100644 --- a/tests/web/test_web_client.py +++ b/tests/web/test_web_client.py @@ -16,6 +16,11 @@ def setUp(self): token="xoxp-1234", base_url="http://localhost:8888", ) + self.async_client = slack.WebClient( + token="xoxp-1234", + run_async=True, + base_url="http://localhost:8888", + ) def tearDown(self): cleanup_mock_web_api_server(self) @@ -106,3 +111,16 @@ def test_the_api_call_files_argument_creates_the_expected_data(self): self.client.token = "xoxb-users_setPhoto" resp = self.client.users_setPhoto(image="tests/data/slack_logo.png") self.assertEqual(200, resp.status_code) + + def test_issue_560_bool_in_params_sync(self): + self.client.token = "xoxb-conversations_list" + self.client.conversations_list(exclude_archived=1) # ok + self.client.conversations_list(exclude_archived="true") # ok + self.client.conversations_list(exclude_archived=True) # ok + + @async_test + async def test_issue_560_bool_in_params_async(self): + self.async_client.token = "xoxb-conversations_list" + await self.async_client.conversations_list(exclude_archived=1) # ok + await self.async_client.conversations_list(exclude_archived="true") # ok + await self.async_client.conversations_list(exclude_archived=True) # TypeError \ No newline at end of file From 59fd21d772c235785762fdc289acb1ec59304d89 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Apr 2020 19:54:18 +0900 Subject: [PATCH 07/12] Apply formatter and organize imports --- integration_tests/rtm/test_issue_605.py | 2 +- integration_tests/web/test_issue_560.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/integration_tests/rtm/test_issue_605.py b/integration_tests/rtm/test_issue_605.py index ccb218301..a41466411 100644 --- a/integration_tests/rtm/test_issue_605.py +++ b/integration_tests/rtm/test_issue_605.py @@ -1,4 +1,4 @@ -import asyncio +import collections import collections import logging import os diff --git a/integration_tests/web/test_issue_560.py b/integration_tests/web/test_issue_560.py index e67170989..1e8e532a8 100644 --- a/integration_tests/web/test_issue_560.py +++ b/integration_tests/web/test_issue_560.py @@ -3,10 +3,8 @@ import os import unittest -import pytest - from integration_tests.env_variable_names import SLACK_SDK_TEST_BOT_TOKEN -from integration_tests.helpers import async_test, is_not_specified +from integration_tests.helpers import async_test from slack import WebClient @@ -19,7 +17,7 @@ class TestWebClient(unittest.TestCase): def setUp(self): self.logger = logging.getLogger(__name__) self.bot_token = os.environ[SLACK_SDK_TEST_BOT_TOKEN] - self.sync_client: WebClient = WebClient(token=self.bot_token, run_async=False, loop=asyncio.new_event_loop()) + self.sync_client: WebClient = WebClient(token=self.bot_token, run_async=False) self.async_client: WebClient = WebClient(token=self.bot_token, run_async=True) def tearDown(self): @@ -51,4 +49,4 @@ def test_issue_560_failure(self): async def test_issue_560_failure_async(self): client = self.async_client response = await client.conversations_list(exclude_archived=True) - self.assertIsNotNone(response) \ No newline at end of file + self.assertIsNotNone(response) From 1ba23784b9b49c980faf17f1bc8a07e5b7aa7a80 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Apr 2020 19:58:28 +0900 Subject: [PATCH 08/12] Add an integration test verifying #378 is no longer an issue --- integration_tests/web/test_issue_378.py | 34 ++++++++++++++++++++++++ tests/data/slack_logo_new.png | Bin 0 -> 47343 bytes 2 files changed, 34 insertions(+) create mode 100644 integration_tests/web/test_issue_378.py create mode 100644 tests/data/slack_logo_new.png diff --git a/integration_tests/web/test_issue_378.py b/integration_tests/web/test_issue_378.py new file mode 100644 index 000000000..2593ed888 --- /dev/null +++ b/integration_tests/web/test_issue_378.py @@ -0,0 +1,34 @@ +import logging +import os +import unittest + +from integration_tests.env_variable_names import SLACK_SDK_TEST_USER_TOKEN +from integration_tests.helpers import async_test +from slack import WebClient + + +class TestWebClient(unittest.TestCase): + """Runs integration tests with real Slack API + + https://github.com/slackapi/python-slackclient/issues/378 + """ + + def setUp(self): + self.logger = logging.getLogger(__name__) + self.user_token = os.environ[SLACK_SDK_TEST_USER_TOKEN] + self.sync_client: WebClient = WebClient(token=self.user_token, run_async=False) + self.async_client: WebClient = WebClient(token=self.user_token, run_async=True) + + def tearDown(self): + pass + + def test_issue_378(self): + client = self.sync_client + response = client.users_setPhoto(image="tests/data/slack_logo_new.png") + self.assertIsNotNone(response) + + @async_test + async def test_issue_378(self): + client = self.sync_client + response = client.users_setPhoto(image="tests/data/slack_logo_new.png") + self.assertIsNotNone(response) diff --git a/tests/data/slack_logo_new.png b/tests/data/slack_logo_new.png new file mode 100644 index 0000000000000000000000000000000000000000..2b95382ce51aa69dc7b5d3389d48a7e4d26608e4 GIT binary patch literal 47343 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

{pi{rW>$sH@ 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 Date: Wed, 29 Apr 2020 14:51:21 +0900 Subject: [PATCH 09/12] Skip depreaction warnings in unit tests and fix an integration test --- integration_tests/web/test_issue_594.py | 4 ++-- slack/web/__init__.py | 4 ++++ tests/web/test_web_client_coverage.py | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/integration_tests/web/test_issue_594.py b/integration_tests/web/test_issue_594.py index dcbc3919d..f225f65bd 100644 --- a/integration_tests/web/test_issue_594.py +++ b/integration_tests/web/test_issue_594.py @@ -48,7 +48,7 @@ def test_issue_594(self): message = client.chat_postEphemeral( channel=self.channel_id, - user="U03E94MK0", + user=self.user_id, blocks=[ { "type": "section", @@ -83,7 +83,7 @@ def test_no_preview_image(self): message = client.chat_postEphemeral( channel=self.channel_id, - user="U03E94MK0", + user=self.user_id, blocks=[ { "type": "section", diff --git a/slack/web/__init__.py b/slack/web/__init__.py index 9d7fb1de8..9617c673a 100644 --- a/slack/web/__init__.py +++ b/slack/web/__init__.py @@ -1,3 +1,4 @@ +import os import platform import sys import warnings @@ -58,6 +59,9 @@ def convert_bool_to_0_or_1(params: Dict[str, any]) -> Dict[str, any]: def show_2020_01_deprecation(method_name: str): + skip_deprecation = os.environ.get("SLACKCLIENT_SKIP_DEPRECATION") # for unit tests etc. + if skip_deprecation: + return if not method_name: return diff --git a/tests/web/test_web_client_coverage.py b/tests/web/test_web_client_coverage.py index 8c7334433..3f13e039d 100644 --- a/tests/web/test_web_client_coverage.py +++ b/tests/web/test_web_client_coverage.py @@ -1,3 +1,4 @@ +import os import unittest import slack @@ -12,6 +13,7 @@ class TestWebClientCoverage(unittest.TestCase): ) api_methods_to_call = [] + os.environ.setdefault("SLACKCLIENT_SKIP_DEPRECATION", "1") def setUp(self): setup_mock_web_api_server(self) From 163a997bb0844f817e3a1f64ad66bea5dd3712b6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 29 Apr 2020 15:33:13 +0900 Subject: [PATCH 10/12] Add a test case for #480 and some fixes --- integration_tests/web/test_issue_378.py | 6 +-- integration_tests/web/test_issue_480.py | 66 +++++++++++++++++++++++++ slack/web/__init__.py | 4 +- 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 integration_tests/web/test_issue_480.py diff --git a/integration_tests/web/test_issue_378.py b/integration_tests/web/test_issue_378.py index 2593ed888..af2a2f3fb 100644 --- a/integration_tests/web/test_issue_378.py +++ b/integration_tests/web/test_issue_378.py @@ -28,7 +28,7 @@ def test_issue_378(self): self.assertIsNotNone(response) @async_test - async def test_issue_378(self): - client = self.sync_client - response = client.users_setPhoto(image="tests/data/slack_logo_new.png") + async def test_issue_378_async(self): + client = self.async_client + response = await client.users_setPhoto(image="tests/data/slack_logo_new.png") self.assertIsNotNone(response) diff --git a/integration_tests/web/test_issue_480.py b/integration_tests/web/test_issue_480.py new file mode 100644 index 000000000..13db62aae --- /dev/null +++ b/integration_tests/web/test_issue_480.py @@ -0,0 +1,66 @@ +import logging +import multiprocessing +import os +import threading +import unittest + +from integration_tests.env_variable_names import SLACK_SDK_TEST_USER_TOKEN +from integration_tests.helpers import async_test +from slack import WebClient + + +class TestWebClient(unittest.TestCase): + """Runs integration tests with real Slack API + + https://github.com/slackapi/python-slackclient/issues/480 + """ + + def setUp(self): + self.logger = logging.getLogger(__name__) + self.user_token = os.environ[SLACK_SDK_TEST_USER_TOKEN] + self.sync_client: WebClient = WebClient(token=self.user_token, run_async=False) + self.async_client: WebClient = WebClient(token=self.user_token, run_async=True) + + def tearDown(self): + pass + + def test_issue_480_processes(self): + client = self.sync_client + before = len(multiprocessing.active_children()) + for idx in range(10): + response = client.api_test() + self.assertIsNotNone(response) + after = len(multiprocessing.active_children()) + self.assertEqual(0, after - before) + + @async_test + async def test_issue_480_processes_async(self): + client = self.async_client + before = len(multiprocessing.active_children()) + for idx in range(10): + response = await client.api_test() + self.assertIsNotNone(response) + after = len(multiprocessing.active_children()) + self.assertEqual(0, after - before) + + # fails with Python 3.6 + def test_issue_480_threads(self): + client = self.sync_client + before = threading.active_count() + for idx in range(10): + response = client.api_test() + self.assertIsNotNone(response) + after = threading.active_count() + self.assertEqual(0, after - before) + + # fails with Python 3.6 + @async_test + async def test_issue_480_threads_async(self): + client = self.async_client + before = threading.active_count() + for idx in range(10): + response = await client.api_test() + self.assertIsNotNone(response) + after = threading.active_count() + self.assertEqual(0, after - before) + diff --git a/slack/web/__init__.py b/slack/web/__init__.py index 9617c673a..047878121 100644 --- a/slack/web/__init__.py +++ b/slack/web/__init__.py @@ -59,7 +59,9 @@ def convert_bool_to_0_or_1(params: Dict[str, any]) -> Dict[str, any]: def show_2020_01_deprecation(method_name: str): - skip_deprecation = os.environ.get("SLACKCLIENT_SKIP_DEPRECATION") # for unit tests etc. + skip_deprecation = os.environ.get( + "SLACKCLIENT_SKIP_DEPRECATION" + ) # for unit tests etc. if skip_deprecation: return if not method_name: From 5f813f231543619fb0c1b5a3f6df3408a813572f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 30 Apr 2020 17:44:01 +0900 Subject: [PATCH 11/12] Fix anti-patterns detected by DeepSource --- slack/web/urllib_client.py | 25 ++++++++++++++++--------- tests/web/test_web_client.py | 8 +++++++- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/slack/web/urllib_client.py b/slack/web/urllib_client.py index f7882a314..134c18e84 100644 --- a/slack/web/urllib_client.py +++ b/slack/web/urllib_client.py @@ -10,6 +10,7 @@ from urllib.parse import urlencode from urllib.request import Request, urlopen +from slack.errors import SlackRequestError from slack.web import get_user_agent, show_2020_01_deprecation, convert_bool_to_0_or_1 from slack.web.slack_response import SlackResponse @@ -25,7 +26,7 @@ def __init__( self, *, token: str = None, - default_headers: Dict[str, str] = dict(), + default_headers: Dict[str, str] = {}, # Not type here to avoid ImportError: cannot import name 'WebClient' from partially initialized module # 'slack.web.client' (most likely due to a circular import web_client=None, @@ -45,11 +46,11 @@ def api_call( *, token: str = None, url: str, - query_params: Dict[str, str] = dict(), - json_body: Dict = dict(), - body_params: Dict[str, str] = dict(), - files: Dict[str, io.BytesIO] = dict(), - additional_headers: Dict[str, str] = dict(), + query_params: Dict[str, str] = {}, + json_body: Dict = {}, + body_params: Dict[str, str] = {}, + files: Dict[str, io.BytesIO] = {}, + additional_headers: Dict[str, str] = {}, ) -> SlackResponse: """Performs a Slack API request and returns the result. @@ -207,10 +208,16 @@ def _perform_http_request( if isinstance(body, str): body = body.encode("utf-8") + # NOTE: Intentionally ignore the `http_verb` here + # Slack APIs accepts any API method requests with POST methods try: - # NOTE: Intentionally ignore the `http_verb` here - # Slack APIs accepts any API method requests with POST methods - req = Request(method="POST", url=url, data=body, headers=headers) + # urllib not only opens http:// or https:// URLs, but also ftp:// and file://. + # With this it might be possible to open local files on the executing machine + # which might be a security risk if the URL to open can be manipulated by an external user. + if url.lower().startswith("http"): + req = Request(method="POST", url=url, data=body, headers=headers) + else: + raise SlackRequestError(f"Invalid URL detected: {url}") resp: HTTPResponse = urlopen(req) charset = resp.headers.get_content_charset() body: str = resp.read().decode(charset) # read the response body here diff --git a/tests/web/test_web_client.py b/tests/web/test_web_client.py index 52af1aaa3..114773981 100644 --- a/tests/web/test_web_client.py +++ b/tests/web/test_web_client.py @@ -4,6 +4,7 @@ import slack import slack.errors as err +from slack.web.urllib_client import UrllibWebClient from tests.helpers import async_test from tests.web.mock_web_api_server import setup_mock_web_api_server, cleanup_mock_web_api_server @@ -123,4 +124,9 @@ async def test_issue_560_bool_in_params_async(self): self.async_client.token = "xoxb-conversations_list" await self.async_client.conversations_list(exclude_archived=1) # ok await self.async_client.conversations_list(exclude_archived="true") # ok - await self.async_client.conversations_list(exclude_archived=True) # TypeError \ No newline at end of file + await self.async_client.conversations_list(exclude_archived=True) # TypeError + + def test_urlib_client_invalid_url(self): + client = UrllibWebClient(token = "xoxb-xxxx") + with self.assertRaises(err.SlackRequestError): + client.api_call(url="file:///Users/alice/.bash_profile") From 48c6799b04cc7f7e64bce29d53c8d6c486e6a1fc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 30 Apr 2020 17:16:51 +0900 Subject: [PATCH 12/12] Fix #611 - stop propagating user exceptions to the connection management layer --- slack/rtm/client.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/slack/rtm/client.py b/slack/rtm/client.py index 900c7a0b0..1071f1b56 100644 --- a/slack/rtm/client.py +++ b/slack/rtm/client.py @@ -402,7 +402,17 @@ async def _read_messages(self): if message.type == aiohttp.WSMsgType.TEXT: payload = message.json() event = payload.pop("type", "Unknown") - await self._dispatch_event(event, data=payload) + try: + await self._dispatch_event(event, data=payload) + except Exception as err: + data = message.data if message else message + self._logger.info( + f"Caught a raised exception ({err}) while dispatching a TEXT message ({data})" + ) + # Raised exceptions here happen in users' code and were just unhandled. + # As they're not intended for closing current WebSocket connection, + # this exception should not be propagated to higher level (#_connect_and_read()). + return elif message.type == aiohttp.WSMsgType.ERROR: self._logger.error("Received an error on the websocket: %r", message) await self._dispatch_event(event="error", data=message)