diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index fceb760e6..bacc54b49 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -10,10 +10,19 @@ jobs: build: # Avoiding -latest due to https://github.com/actions/setup-python/issues/162 runs-on: ubuntu-20.04 - timeout-minutes: 10 + timeout-minutes: 15 strategy: + fail-fast: false matrix: - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: + - '3.12' + - '3.11' + - '3.10' + - '3.9' + - '3.8' + - '3.7' + - '3.6' + - 'pypy3.10' env: PYTHON_SLACK_SDK_MOCK_SERVER_MODE: 'threading' CI_LARGE_SOCKET_MODE_PAYLOAD_TESTING_DISABLED: '1' @@ -23,12 +32,13 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + cache: pip - name: Install dependencies run: | pip install -U pip wheel pip install -e ".[testing]" pip install -e ".[optional]" - - name: Run validation + - name: Run validation (black/flake8/pytest) run: | python setup.py validate - name: Run tests for SQLAlchemy v1.4 (backward-compatibility) diff --git a/pytest.ini b/pytest.ini index c9030f7af..7e88e4b78 100644 --- a/pytest.ini +++ b/pytest.ini @@ -6,4 +6,5 @@ log_date_format = %Y-%m-%d %H:%M:%S filterwarnings = ignore:"@coroutine" decorator is deprecated since Python 3.8, use "async def" instead:DeprecationWarning ignore:The loop argument is deprecated since Python 3.8, and scheduled for removal in Python 3.10.:DeprecationWarning + ignore:slack.* package is deprecated. Please use slack_sdk.* package instead.*:UserWarning asyncio_mode = auto diff --git a/setup.py b/setup.py index 253332ae6..fd391b1b2 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ long_description = readme.read() validate_dependencies = [ - "pytest>=6.2.5,<7", + "pytest>=7.0.1,<8", "pytest-asyncio<1", # for async "Flask-Sockets>=0.2,<1", "Flask>=1,<2", # TODO: Flask-Sockets is not yet compatible with Flask 2.x @@ -25,7 +25,9 @@ "itsdangerous==1.1.0", # TODO: Flask-Sockets is not yet compatible with Flask 2.x "Jinja2==3.0.3", # https://github.com/pallets/flask/issues/4494 "pytest-cov>=2,<3", - "flake8>=5,<6", + # while flake8 5.x have issues with Python 3.12, flake8 6.x requires Python >= 3.8.1, + # so 5.x should be kept in order to stay compatible with Python 3.6/3.7 + "flake8>=5.0.4,<7", # Don't change this version without running CI builds; # The latest version may not be available for older Python runtime "black==22.8.0", @@ -123,7 +125,7 @@ def run(self): async_source = header + source async_source = re.sub(" def ", " async def ", async_source) async_source = re.sub("from asyncio import Future\n", "", async_source) - async_source = re.sub("return self.api_call\(", "return await self.api_call(", async_source) + async_source = re.sub(r"return self.api_call\(", "return await self.api_call(", async_source) async_source = re.sub("-> SlackResponse", "-> AsyncSlackResponse", async_source) async_source = re.sub( "from .base_client import BaseClient, SlackResponse", @@ -132,7 +134,7 @@ def run(self): ) # from slack_sdk import WebClient async_source = re.sub( - "class WebClient\(BaseClient\):", + r"class WebClient\(BaseClient\):", "class AsyncWebClient(AsyncBaseClient):", async_source, ) @@ -141,19 +143,19 @@ def run(self): "from slack_sdk.web.async_client import AsyncWebClient", async_source, ) - async_source = re.sub("= WebClient\(", "= AsyncWebClient(", async_source) + async_source = re.sub(r"= WebClient\(", "= AsyncWebClient(", async_source) async_source = re.sub( - " self.files_getUploadURLExternal\(", + r" self.files_getUploadURLExternal\(", " await self.files_getUploadURLExternal(", async_source, ) async_source = re.sub( - " self.files_completeUploadExternal\(", + r" self.files_completeUploadExternal\(", " await self.files_completeUploadExternal(", async_source, ) async_source = re.sub( - " self.files_info\(", + r" self.files_info\(", " await self.files_info(", async_source, ) @@ -163,7 +165,7 @@ def run(self): async_source, ) async_source = re.sub( - " _attach_full_file_metadata_async\(", + r" _attach_full_file_metadata_async\(", " await _attach_full_file_metadata_async(", async_source, ) @@ -178,7 +180,7 @@ def run(self): legacy_source, ) legacy_source = re.sub( - "class WebClient\(BaseClient\):", + r"class WebClient\(BaseClient\):", "class LegacyWebClient(LegacyBaseClient):", legacy_source, ) @@ -187,7 +189,7 @@ def run(self): "from slack_sdk.web.legacy_client import LegacyWebClient", legacy_source, ) - legacy_source = re.sub("= WebClient\(", "= LegacyWebClient(", legacy_source) + legacy_source = re.sub(r"= WebClient\(", "= LegacyWebClient(", legacy_source) with open(f"{here}/slack_sdk/web/legacy_client.py", "w") as output: output.write(legacy_source) @@ -212,8 +214,10 @@ def run(self): "Installing test dependencies ...", [sys.executable, "-m", "pip", "install"] + validate_dependencies, ) - self._run("Running black ...", [sys.executable, "-m", "black", f"{here}/slack"]) - self._run("Running black ...", [sys.executable, "-m", "black", f"{here}/slack_sdk"]) + + self._run("Running black for legacy packages ...", [sys.executable, "-m", "black", f"{here}/slack"]) + self._run("Running black for slack_sdk package ...", [sys.executable, "-m", "black", f"{here}/slack_sdk"]) + self._run("Running flake8 for legacy packages ...", [sys.executable, "-m", "flake8", f"{here}/slack"]) self._run("Running flake8 for slack_sdk package ...", [sys.executable, "-m", "flake8", f"{here}/slack_sdk"]) @@ -301,6 +305,7 @@ def run(self): "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", @@ -308,6 +313,8 @@ def run(self): "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", ], keywords="slack slack-api web-api slack-rtm websocket chat chatbot chatops", packages=find_packages( diff --git a/slack_sdk/oauth/installation_store/internals.py b/slack_sdk/oauth/installation_store/internals.py index f0b31d394..1c3bfccaf 100644 --- a/slack_sdk/oauth/installation_store/internals.py +++ b/slack_sdk/oauth/installation_store/internals.py @@ -1,32 +1,47 @@ -import platform -import datetime - -(major, minor, patch) = platform.python_version_tuple() -is_python_3_6: bool = int(major) == 3 and int(minor) >= 6 - -utc_timezone = datetime.timezone.utc - - -def _from_iso_format_to_datetime(iso_datetime_str: str) -> datetime.datetime: - if is_python_3_6: - elements = iso_datetime_str.split(" ") - ymd = elements[0].split("-") - hms = elements[1].split(":") - return datetime.datetime( - int(ymd[0]), - int(ymd[1]), - int(ymd[2]), - int(hms[0]), - int(hms[1]), - int(hms[2]), - 0, - utc_timezone, - ) +import sys +from datetime import datetime, timezone +from typing import Type, TypeVar, Union + + +def _from_iso_format_to_datetime(iso_datetime_str: str) -> datetime: + if sys.version_info[:2] == (3, 6): + format = "%Y-%m-%d %H:%M:%S" + if "." in iso_datetime_str: + format += ".%f" + return datetime.strptime(iso_datetime_str, format).replace(tzinfo=timezone.utc) else: if "+" not in iso_datetime_str: iso_datetime_str += "+00:00" - return datetime.datetime.fromisoformat(iso_datetime_str) + return datetime.fromisoformat(iso_datetime_str) def _from_iso_format_to_unix_timestamp(iso_datetime_str: str) -> float: return _from_iso_format_to_datetime(iso_datetime_str).timestamp() + + +TimestampType = TypeVar("TimestampType", float, int) + + +def _timestamp_to_type(ts: Union[TimestampType, datetime, str], target_type: Type[TimestampType]) -> TimestampType: + result: TimestampType + + if isinstance(ts, target_type): + # unnecessary type casting makes pytype happy + result = target_type(ts) + + # although a type of the timestamp is just checked, + # pytype doesn't consider the following line valid: + # result = ts + # see https://github.com/google/pytype/issues/1012 + + elif isinstance(ts, datetime): + result = target_type(ts.timestamp()) + elif isinstance(ts, str): + try: + result = target_type(ts) + except ValueError: + result = target_type(_from_iso_format_to_unix_timestamp(ts)) + else: + raise ValueError(f"Unsupported data format for timestamp {ts}") + + return result diff --git a/slack_sdk/oauth/installation_store/models/bot.py b/slack_sdk/oauth/installation_store/models/bot.py index 36be38644..e060b8489 100644 --- a/slack_sdk/oauth/installation_store/models/bot.py +++ b/slack_sdk/oauth/installation_store/models/bot.py @@ -1,11 +1,8 @@ -import re from datetime import datetime # type: ignore from time import time from typing import Optional, Union, Dict, Any, Sequence -from slack_sdk.oauth.installation_store.internals import ( - _from_iso_format_to_unix_timestamp, -) +from slack_sdk.oauth.installation_store.internals import _timestamp_to_type class Bot: @@ -70,30 +67,17 @@ def __init__( else: self.bot_scopes = bot_scopes self.bot_refresh_token = bot_refresh_token + if bot_token_expires_at is not None: - if type(bot_token_expires_at) == datetime: - self.bot_token_expires_at = int(bot_token_expires_at.timestamp()) # type: ignore - elif type(bot_token_expires_at) == str and not re.match("^\\d+$", bot_token_expires_at): - self.bot_token_expires_at = int(_from_iso_format_to_unix_timestamp(bot_token_expires_at)) - else: - self.bot_token_expires_at = int(bot_token_expires_at) + self.bot_token_expires_at = _timestamp_to_type(bot_token_expires_at, int) elif bot_token_expires_in is not None: self.bot_token_expires_at = int(time()) + bot_token_expires_in else: self.bot_token_expires_at = None + self.is_enterprise_install = is_enterprise_install or False - if type(installed_at) == float: - self.installed_at = installed_at # type: ignore - elif type(installed_at) == datetime: - self.installed_at = installed_at.timestamp() # type: ignore - elif type(installed_at) == str: - if re.match("^\\d+.\\d+$", installed_at): - self.installed_at = float(installed_at) - else: - self.installed_at = _from_iso_format_to_unix_timestamp(installed_at) - else: - raise ValueError(f"Unsupported data format for installed_at {installed_at}") + self.installed_at = _timestamp_to_type(installed_at, float) self.custom_values = custom_values if custom_values is not None else {} diff --git a/slack_sdk/oauth/installation_store/models/installation.py b/slack_sdk/oauth/installation_store/models/installation.py index e55cf2b98..09418b36d 100644 --- a/slack_sdk/oauth/installation_store/models/installation.py +++ b/slack_sdk/oauth/installation_store/models/installation.py @@ -1,11 +1,8 @@ -import re from datetime import datetime # type: ignore from time import time from typing import Optional, Union, Dict, Any, Sequence -from slack_sdk.oauth.installation_store.internals import ( - _from_iso_format_to_unix_timestamp, -) +from slack_sdk.oauth.installation_store.internals import _timestamp_to_type from slack_sdk.oauth.installation_store.models.bot import Bot @@ -100,14 +97,9 @@ def __init__( else: self.bot_scopes = bot_scopes self.bot_refresh_token = bot_refresh_token + if bot_token_expires_at is not None: - if type(bot_token_expires_at) == datetime: - ts: float = bot_token_expires_at.timestamp() # type: ignore - self.bot_token_expires_at = int(ts) - elif type(bot_token_expires_at) == str and not re.match("^\\d+$", bot_token_expires_at): - self.bot_token_expires_at = int(_from_iso_format_to_unix_timestamp(bot_token_expires_at)) - else: - self.bot_token_expires_at = bot_token_expires_at # type: ignore + self.bot_token_expires_at = _timestamp_to_type(bot_token_expires_at, int) elif bot_token_expires_in is not None: self.bot_token_expires_at = int(time()) + bot_token_expires_in else: @@ -120,14 +112,9 @@ def __init__( else: self.user_scopes = user_scopes self.user_refresh_token = user_refresh_token + if user_token_expires_at is not None: - if type(user_token_expires_at) == datetime: - ts: float = user_token_expires_at.timestamp() # type: ignore - self.user_token_expires_at = int(ts) - elif type(user_token_expires_at) == str and not re.match("^\\d+$", user_token_expires_at): - self.user_token_expires_at = int(_from_iso_format_to_unix_timestamp(user_token_expires_at)) - else: - self.user_token_expires_at = user_token_expires_at # type: ignore + self.user_token_expires_at = _timestamp_to_type(user_token_expires_at, int) elif user_token_expires_in is not None: self.user_token_expires_at = int(time()) + user_token_expires_in else: @@ -143,17 +130,8 @@ def __init__( if installed_at is None: self.installed_at = datetime.now().timestamp() - elif type(installed_at) == float: - self.installed_at = installed_at # type: ignore - elif type(installed_at) == datetime: - self.installed_at = installed_at.timestamp() # type: ignore - elif type(installed_at) == str: - if re.match("^\\d+.\\d+$", installed_at): - self.installed_at = float(installed_at) - else: - self.installed_at = _from_iso_format_to_unix_timestamp(installed_at) else: - raise ValueError(f"Unsupported data format for installed_at {installed_at}") + self.installed_at = _timestamp_to_type(installed_at, float) self.custom_values = custom_values if custom_values is not None else {} diff --git a/tests/slack_sdk/oauth/installation_store/test_internals.py b/tests/slack_sdk/oauth/installation_store/test_internals.py index 5d0f3d435..948aa8c15 100644 --- a/tests/slack_sdk/oauth/installation_store/test_internals.py +++ b/tests/slack_sdk/oauth/installation_store/test_internals.py @@ -1,7 +1,11 @@ +import sys import unittest +from datetime import datetime, timezone + +import pytest from slack_sdk.oauth.installation_store import Installation, FileInstallationStore -from slack_sdk.oauth.installation_store.internals import _from_iso_format_to_datetime +from slack_sdk.oauth.installation_store.internals import _from_iso_format_to_datetime, _timestamp_to_type class TestFile(unittest.TestCase): @@ -14,3 +18,29 @@ def tearDown(self): def test_iso_format(self): dt = _from_iso_format_to_datetime("2021-07-14 08:00:17") self.assertEqual(dt.timestamp(), 1626249617.0) + + +@pytest.mark.parametrize('ts,target_type,expected_result', [ + (1701209097, int, 1701209097), + (datetime(2023, 11, 28, 22, 9, 7, tzinfo=timezone.utc), int, 1701209347), + ("1701209605", int, 1701209605), + ("2023-11-28 22:11:19", int, 1701209479), + (1701209998.3429494, float, 1701209998.3429494), + (datetime(2023, 11, 28, 22, 20, 25, 262571, tzinfo=timezone.utc), float, 1701210025.262571), + ("1701210054.4672053", float, 1701210054.4672053), + ("2023-11-28 22:21:14.745556", float, 1701210074.745556), +]) +def test_timestamp_to_type(ts, target_type, expected_result): + result = _timestamp_to_type(ts, target_type) + assert result == expected_result + + +def test_timestamp_to_type_invalid_str(): + match = "Invalid isoformat string" if sys.version_info[:2] > (3, 6) else "time data .* does not match format" + with pytest.raises(ValueError, match=match): + _timestamp_to_type('not-a-timestamp', int) + + +def test_timestamp_to_type_unsupported_format(): + with pytest.raises(ValueError, match="Unsupported data format"): + _timestamp_to_type({}, int) diff --git a/tests/slack_sdk_async/socket_mode/test_interactions_websockets.py b/tests/slack_sdk_async/socket_mode/test_interactions_websockets.py index a935c0f3c..b1b7c1652 100644 --- a/tests/slack_sdk_async/socket_mode/test_interactions_websockets.py +++ b/tests/slack_sdk_async/socket_mode/test_interactions_websockets.py @@ -90,7 +90,9 @@ async def socket_mode_listener( expected.sort() count = 0 - while count < 10 and len(received_messages) < len(expected): + while count < 10 and ( + len(received_messages) < len(expected) or len(received_socket_mode_requests) < len(socket_mode_envelopes) + ): await asyncio.sleep(0.2) count += 0.2