Skip to content

Commit

Permalink
Enable testing for Python 3.12 and PyPy 3.10 on CI (#1435)
Browse files Browse the repository at this point in the history
These changes also:
  * resolve/suppress some warnings
  * update classifiers at setup.py
  * bump pytest to 7.x
  * update constraints for flake8 to allow pip
    do its job and resolve compatibility issues
  * refactor timestamp conversion logic in order to
    resolve a bunch of E721 flake8 errors
  * fix some regexes with invalid escaping at setup.py
  * fix spontaneous failures of TestInteractionsWebsockets.test_interactions
  • Loading branch information
Jamim authored Nov 30, 2023
1 parent 661eeec commit 9c4d37c
Show file tree
Hide file tree
Showing 8 changed files with 119 additions and 92 deletions.
16 changes: 13 additions & 3 deletions .github/workflows/ci-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
33 changes: 20 additions & 13 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@
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
"Werkzeug<2", # TODO: Flask-Sockets is not yet compatible with Flask 2.x
"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",
Expand Down Expand Up @@ -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",
Expand All @@ -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,
)
Expand All @@ -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,
)
Expand All @@ -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,
)
Expand All @@ -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,
)
Expand All @@ -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)

Expand All @@ -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"])

Expand Down Expand Up @@ -301,13 +305,16 @@ 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",
"Programming Language :: Python :: 3.9",
"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(
Expand Down
65 changes: 40 additions & 25 deletions slack_sdk/oauth/installation_store/internals.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 5 additions & 21 deletions slack_sdk/oauth/installation_store/models/bot.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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 {}

Expand Down
34 changes: 6 additions & 28 deletions slack_sdk/oauth/installation_store/models/installation.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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 {}

Expand Down
32 changes: 31 additions & 1 deletion tests/slack_sdk/oauth/installation_store/test_internals.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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)
Loading

0 comments on commit 9c4d37c

Please sign in to comment.