From d0a6e5141c7b8a201eb93dc3282fec6d18c43c78 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jan 2025 19:43:52 +0100 Subject: [PATCH] Bump APS & Deprecate `pytz` Support (#4582) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> --- .github/workflows/unit_tests.yml | 3 +- README.rst | 2 +- pyproject.toml | 4 +-- requirements-unit-tests.txt | 6 +++- telegram/_utils/datetime.py | 41 +++++++++++++++-------- telegram/ext/_defaults.py | 22 ++++++++++-- telegram/ext/_jobqueue.py | 12 ++++--- tests/_utils/test_datetime.py | 57 ++++++++++++++++++++------------ tests/auxil/bot_method_checks.py | 40 +++++++++++++--------- tests/auxil/ci_bots.py | 7 ++-- tests/auxil/envvars.py | 9 +++-- tests/conftest.py | 25 +++++++++----- tests/ext/test_defaults.py | 15 ++++++--- tests/ext/test_jobqueue.py | 29 +++++++++++++--- tests/ext/test_ratelimiter.py | 4 +-- tests/test_bot.py | 11 +++--- tests/test_constants.py | 2 +- 17 files changed, 190 insertions(+), 99 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 7503d2370..e6fd437c6 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -64,7 +64,8 @@ jobs: # Test the rest export TEST_WITH_OPT_DEPS='true' - pip install .[all] + # need to manually install pytz here, because it's no longer in the optional reqs + pip install .[all] pytz # `-n auto --dist worksteal` uses pytest-xdist to run tests on multiple CPU # workers. Increasing number of workers has little effect on test duration, but it seems # to increase flakyness. diff --git a/README.rst b/README.rst index 3721a834f..f93c1d8c9 100644 --- a/README.rst +++ b/README.rst @@ -158,7 +158,7 @@ PTB can be installed with optional dependencies: * ``pip install "python-telegram-bot[rate-limiter]"`` installs `aiolimiter~=1.1,<1.3 `_. Use this, if you want to use ``telegram.ext.AIORateLimiter``. * ``pip install "python-telegram-bot[webhooks]"`` installs the `tornado~=6.4 `_ library. Use this, if you want to use ``telegram.ext.Updater.start_webhook``/``telegram.ext.Application.run_webhook``. * ``pip install "python-telegram-bot[callback-data]"`` installs the `cachetools>=5.3.3,<5.6.0 `_ library. Use this, if you want to use `arbitrary callback_data `_. -* ``pip install "python-telegram-bot[job-queue]"`` installs the `APScheduler~=3.10.4 `_ library and enforces `pytz>=2018.6 `_, where ``pytz`` is a dependency of ``APScheduler``. Use this, if you want to use the ``telegram.ext.JobQueue``. +* ``pip install "python-telegram-bot[job-queue]"`` installs the `APScheduler~=3.10.4 `_ library. Use this, if you want to use the ``telegram.ext.JobQueue``. To install multiple optional dependencies, separate them by commas, e.g. ``pip install "python-telegram-bot[socks,webhooks]"``. diff --git a/pyproject.toml b/pyproject.toml index 6fba96529..01406663f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,9 +76,7 @@ http2 = [ ] job-queue = [ # APS doesn't have a strict stability policy. Let's be cautious for now. - "APScheduler~=3.10.4", - # pytz is required by APS and just needs the lower bound due to #2120 - "pytz>=2018.6", + "APScheduler>=3.10.4,<3.12.0", ] passport = [ "cryptography!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=39.0.1", diff --git a/requirements-unit-tests.txt b/requirements-unit-tests.txt index 654bc9e9f..a9c4ba3c2 100644 --- a/requirements-unit-tests.txt +++ b/requirements-unit-tests.txt @@ -16,4 +16,8 @@ pytest-xdist==3.6.1 flaky>=3.8.1 # used in test_official for parsing tg docs -beautifulsoup4 \ No newline at end of file +beautifulsoup4 + +# For testing with timezones. Might not be needed on all systems, but to ensure that unit tests +# run correctly on all systems, we include it here. +tzdata \ No newline at end of file diff --git a/telegram/_utils/datetime.py b/telegram/_utils/datetime.py index 8a6b158b9..8e6ebdda1 100644 --- a/telegram/_utils/datetime.py +++ b/telegram/_utils/datetime.py @@ -27,6 +27,7 @@ Warning: user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ +import contextlib import datetime as dtm import time from typing import TYPE_CHECKING, Optional, Union @@ -34,22 +35,26 @@ from typing import TYPE_CHECKING, Optional, Union if TYPE_CHECKING: from telegram import Bot -# pytz is only available if it was installed as dependency of APScheduler, so we make a little -# workaround here -DTM_UTC = dtm.timezone.utc +UTC = dtm.timezone.utc try: import pytz - - UTC = pytz.utc except ImportError: - UTC = DTM_UTC # type: ignore[assignment] + pytz = None # type: ignore[assignment] -def _localize(datetime: dtm.datetime, tzinfo: dtm.tzinfo) -> dtm.datetime: - """Localize the datetime, where UTC is handled depending on whether pytz is available or not""" - if tzinfo is DTM_UTC: - return datetime.replace(tzinfo=DTM_UTC) - return tzinfo.localize(datetime) # type: ignore[attr-defined] +def localize(datetime: dtm.datetime, tzinfo: dtm.tzinfo) -> dtm.datetime: + """Localize the datetime, both for pytz and zoneinfo timezones.""" + if tzinfo is UTC: + return datetime.replace(tzinfo=UTC) + + with contextlib.suppress(AttributeError): + # Since pytz might not be available, we need the suppress context manager + if isinstance(tzinfo, pytz.BaseTzInfo): + return tzinfo.localize(datetime) + + if datetime.tzinfo is None: + return datetime.replace(tzinfo=tzinfo) + return datetime.astimezone(tzinfo) def to_float_timestamp( @@ -87,7 +92,7 @@ def to_float_timestamp( will be raised. tzinfo (:class:`datetime.tzinfo`, optional): If :paramref:`time_object` is a naive object from the :mod:`datetime` module, it will be interpreted as this timezone. Defaults to - ``pytz.utc``, if available, and :attr:`datetime.timezone.utc` otherwise. + :attr:`datetime.timezone.utc` otherwise. Note: Only to be used by ``telegram.ext``. @@ -121,6 +126,12 @@ def to_float_timestamp( return reference_timestamp + time_object if tzinfo is None: + # We do this here rather than in the signature to ensure that we can make calls like + # to_float_timestamp( + # time, tzinfo=bot.defaults.tzinfo if bot.defaults else None + # ) + # This ensures clean separation of concerns, i.e. the default timezone should not be + # the responsibility of the caller tzinfo = UTC if isinstance(time_object, dtm.time): @@ -132,7 +143,9 @@ def to_float_timestamp( aware_datetime = dtm.datetime.combine(reference_date, time_object) if aware_datetime.tzinfo is None: - aware_datetime = _localize(aware_datetime, tzinfo) + # datetime.combine uses the tzinfo of `time_object`, which might be None + # so we still need to localize + aware_datetime = localize(aware_datetime, tzinfo) # if the time of day has passed today, use tomorrow if reference_time > aware_datetime.timetz(): @@ -140,7 +153,7 @@ def to_float_timestamp( return _datetime_to_float_timestamp(aware_datetime) if isinstance(time_object, dtm.datetime): if time_object.tzinfo is None: - time_object = _localize(time_object, tzinfo) + time_object = localize(time_object, tzinfo) return _datetime_to_float_timestamp(time_object) raise TypeError(f"Unable to convert {type(time_object).__name__} object to timestamp") diff --git a/telegram/ext/_defaults.py b/telegram/ext/_defaults.py index 69b870603..357e379cf 100644 --- a/telegram/ext/_defaults.py +++ b/telegram/ext/_defaults.py @@ -57,10 +57,13 @@ class Defaults: versions. tzinfo (:class:`datetime.tzinfo`, optional): A timezone to be used for all date(time) inputs appearing throughout PTB, i.e. if a timezone naive date(time) object is passed - somewhere, it will be assumed to be in :paramref:`tzinfo`. If the - :class:`telegram.ext.JobQueue` is used, this must be a timezone provided - by the ``pytz`` module. Defaults to ``pytz.utc``, if available, and + somewhere, it will be assumed to be in :paramref:`tzinfo`. Defaults to :attr:`datetime.timezone.utc` otherwise. + + .. deprecated:: NEXT.VERSION + Support for ``pytz`` timezones is deprecated and will be removed in future + versions. + block (:obj:`bool`, optional): Default setting for the :paramref:`BaseHandler.block` parameter of handlers and error handlers registered through :meth:`Application.add_handler` and @@ -148,6 +151,19 @@ class Defaults: self._block: bool = block self._protect_content: Optional[bool] = protect_content + if "pytz" in str(self._tzinfo.__class__): + # TODO: When dropping support, make sure to update _utils.datetime accordingly + warn( + message=PTBDeprecationWarning( + version="NEXT.VERSION", + message=( + "Support for pytz timezones is deprecated and will be removed in " + "future versions." + ), + ), + stacklevel=2, + ) + if disable_web_page_preview is not None and link_preview_options is not None: raise ValueError( "`disable_web_page_preview` and `link_preview_options` are mutually exclusive." diff --git a/telegram/ext/_jobqueue.py b/telegram/ext/_jobqueue.py index 823d6c80c..14a005f84 100644 --- a/telegram/ext/_jobqueue.py +++ b/telegram/ext/_jobqueue.py @@ -23,7 +23,6 @@ import weakref from typing import TYPE_CHECKING, Any, Generic, Optional, Union, cast, overload try: - import pytz from apscheduler.executors.asyncio import AsyncIOExecutor from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -31,6 +30,7 @@ try: except ImportError: APS_AVAILABLE = False +from telegram._utils.datetime import UTC, localize from telegram._utils.logging import get_logger from telegram._utils.repr import build_repr_with_selected_attrs from telegram._utils.types import JSONDict @@ -155,13 +155,13 @@ class JobQueue(Generic[CCT]): dict[:obj:`str`, :obj:`object`]: The configuration values as dictionary. """ - timezone: object = pytz.utc + timezone: dtm.tzinfo = UTC if ( self._application and isinstance(self.application.bot, ExtBot) and self.application.bot.defaults ): - timezone = self.application.bot.defaults.tzinfo or pytz.utc + timezone = self.application.bot.defaults.tzinfo or UTC return { "timezone": timezone, @@ -197,8 +197,10 @@ class JobQueue(Generic[CCT]): dtm.datetime.now(tz=time.tzinfo or self.scheduler.timezone).date(), time ) if date_time.tzinfo is None: - date_time = self.scheduler.timezone.localize(date_time) - if shift_day and date_time <= dtm.datetime.now(pytz.utc): + # dtm.combine uses the tzinfo of `time`, which might be None, so we still have + # to localize it + date_time = localize(date_time, self.scheduler.timezone) + if shift_day and date_time <= dtm.datetime.now(UTC): date_time += dtm.timedelta(days=1) return date_time return time diff --git a/tests/_utils/test_datetime.py b/tests/_utils/test_datetime.py index d5f138a2e..dfcaca675 100644 --- a/tests/_utils/test_datetime.py +++ b/tests/_utils/test_datetime.py @@ -18,6 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import datetime as dtm import time +import zoneinfo import pytest @@ -55,18 +56,38 @@ with the TEST_WITH_OPT_DEPS=False environment variable in addition to the regula class TestDatetime: - @staticmethod - def localize(dt, tzinfo): - if TEST_WITH_OPT_DEPS: - return tzinfo.localize(dt) - return dt.replace(tzinfo=tzinfo) + def test_localize_utc(self): + dt = dtm.datetime(2023, 1, 1, 12, 0, 0) + localized_dt = tg_dtm.localize(dt, tg_dtm.UTC) + assert localized_dt.tzinfo == tg_dtm.UTC + assert localized_dt == dt.replace(tzinfo=tg_dtm.UTC) - def test_helpers_utc(self): - # Here we just test, that we got the correct UTC variant - if not TEST_WITH_OPT_DEPS: - assert tg_dtm.UTC is tg_dtm.DTM_UTC - else: - assert tg_dtm.UTC is not tg_dtm.DTM_UTC + @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="pytz not installed") + def test_localize_pytz(self): + dt = dtm.datetime(2023, 1, 1, 12, 0, 0) + import pytz + + tzinfo = pytz.timezone("Europe/Berlin") + localized_dt = tg_dtm.localize(dt, tzinfo) + assert localized_dt.hour == dt.hour + assert localized_dt.tzinfo is not None + assert tzinfo.utcoffset(dt) is not None + + def test_localize_zoneinfo_naive(self): + dt = dtm.datetime(2023, 1, 1, 12, 0, 0) + tzinfo = zoneinfo.ZoneInfo("Europe/Berlin") + localized_dt = tg_dtm.localize(dt, tzinfo) + assert localized_dt.hour == dt.hour + assert localized_dt.tzinfo is not None + assert tzinfo.utcoffset(dt) is not None + + def test_localize_zoneinfo_aware(self): + dt = dtm.datetime(2023, 1, 1, 12, 0, 0, tzinfo=dtm.timezone.utc) + tzinfo = zoneinfo.ZoneInfo("Europe/Berlin") + localized_dt = tg_dtm.localize(dt, tzinfo) + assert localized_dt.hour == dt.hour + 1 + assert localized_dt.tzinfo is not None + assert tzinfo.utcoffset(dt) is not None def test_to_float_timestamp_absolute_naive(self): """Conversion from timezone-naive datetime to timestamp. @@ -75,20 +96,12 @@ class TestDatetime: datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5) assert tg_dtm.to_float_timestamp(datetime) == 1573431976.1 - def test_to_float_timestamp_absolute_naive_no_pytz(self, monkeypatch): - """Conversion from timezone-naive datetime to timestamp. - Naive datetimes should be assumed to be in UTC. - """ - monkeypatch.setattr(tg_dtm, "UTC", tg_dtm.DTM_UTC) - datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5) - assert tg_dtm.to_float_timestamp(datetime) == 1573431976.1 - def test_to_float_timestamp_absolute_aware(self, timezone): """Conversion from timezone-aware datetime to timestamp""" # we're parametrizing this with two different UTC offsets to exclude the possibility # of an xpass when the test is run in a timezone with the same UTC offset test_datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5) - datetime = self.localize(test_datetime, timezone) + datetime = tg_dtm.localize(test_datetime, timezone) assert ( tg_dtm.to_float_timestamp(datetime) == 1573431976.1 - timezone.utcoffset(test_datetime).total_seconds() @@ -126,7 +139,7 @@ class TestDatetime: ref_datetime = dtm.datetime(1970, 1, 1, 12) utc_offset = timezone.utcoffset(ref_datetime) ref_t, time_of_day = tg_dtm._datetime_to_float_timestamp(ref_datetime), ref_datetime.time() - aware_time_of_day = self.localize(ref_datetime, timezone).timetz() + aware_time_of_day = tg_dtm.localize(ref_datetime, timezone).timetz() # first test that naive time is assumed to be utc: assert tg_dtm.to_float_timestamp(time_of_day, ref_t) == pytest.approx(ref_t) @@ -169,7 +182,7 @@ class TestDatetime: # we're parametrizing this with two different UTC offsets to exclude the possibility # of an xpass when the test is run in a timezone with the same UTC offset test_datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5) - datetime = self.localize(test_datetime, timezone) + datetime = tg_dtm.localize(test_datetime, timezone) assert ( tg_dtm.from_timestamp(1573431976.1 - timezone.utcoffset(test_datetime).total_seconds()) == datetime diff --git a/tests/auxil/bot_method_checks.py b/tests/auxil/bot_method_checks.py index 5cc760351..7e50a8dae 100644 --- a/tests/auxil/bot_method_checks.py +++ b/tests/auxil/bot_method_checks.py @@ -21,6 +21,7 @@ import datetime as dtm import functools import inspect import re +import zoneinfo from collections.abc import Collection, Iterable from typing import Any, Callable, Optional @@ -40,15 +41,11 @@ from telegram import ( Sticker, TelegramObject, ) +from telegram._utils.datetime import to_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue from telegram.constants import InputMediaType from telegram.ext import Defaults, ExtBot from telegram.request import RequestData -from tests.auxil.envvars import TEST_WITH_OPT_DEPS - -if TEST_WITH_OPT_DEPS: - import pytz - FORWARD_REF_PATTERN = re.compile(r"ForwardRef\('(?P\w+)'\)") """ A pattern to find a class name in a ForwardRef typing annotation. @@ -344,10 +341,10 @@ def build_kwargs( # Some special casing for methods that have "exactly one of the optionals" type args elif name in ["location", "contact", "venue", "inline_message_id"]: kws[name] = True - elif name == "until_date": + elif name.endswith("_date"): if manually_passed_value not in [None, DEFAULT_NONE]: # Europe/Berlin - kws[name] = pytz.timezone("Europe/Berlin").localize(dtm.datetime(2000, 1, 1, 0)) + kws[name] = dtm.datetime(2000, 1, 1, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin")) else: # naive UTC kws[name] = dtm.datetime(2000, 1, 1, 0) @@ -395,6 +392,15 @@ def make_assertion_for_link_preview_options( ) +_EUROPE_BERLIN_TS = to_timestamp( + dtm.datetime(2000, 1, 1, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin")) +) +_UTC_TS = to_timestamp(dtm.datetime(2000, 1, 1, 0), tzinfo=zoneinfo.ZoneInfo("UTC")) +_AMERICA_NEW_YORK_TS = to_timestamp( + dtm.datetime(2000, 1, 1, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York")) +) + + async def make_assertion( url, request_data: RequestData, @@ -530,14 +536,16 @@ async def make_assertion( ) # Check datetime conversion - until_date = data.pop("until_date", None) - if until_date: - if manual_value_expected and until_date != 946681200: - pytest.fail("Non-naive until_date should have been interpreted as Europe/Berlin.") - if not any((manually_passed_value, expected_defaults_value)) and until_date != 946684800: - pytest.fail("Naive until_date should have been interpreted as UTC") - if default_value_expected and until_date != 946702800: - pytest.fail("Naive until_date should have been interpreted as America/New_York") + date_keys = [key for key in data if key.endswith("_date")] + for key in date_keys: + date_param = data.pop(key) + if date_param: + if manual_value_expected and date_param != _EUROPE_BERLIN_TS: + pytest.fail(f"Non-naive `{key}` should have been interpreted as Europe/Berlin.") + if not any((manually_passed_value, expected_defaults_value)) and date_param != _UTC_TS: + pytest.fail(f"Naive `{key}` should have been interpreted as UTC") + if default_value_expected and date_param != _AMERICA_NEW_YORK_TS: + pytest.fail(f"Naive `{key}` should have been interpreted as America/New_York") if method_name in ["get_file", "get_small_file", "get_big_file"]: # This is here mainly for PassportFile.get_file, which calls .set_credentials on the @@ -596,7 +604,7 @@ async def check_defaults_handling( defaults_no_custom_defaults = Defaults() kwargs = {kwarg: "custom_default" for kwarg in inspect.signature(Defaults).parameters} - kwargs["tzinfo"] = pytz.timezone("America/New_York") + kwargs["tzinfo"] = zoneinfo.ZoneInfo("America/New_York") kwargs.pop("disable_web_page_preview") # mutually exclusive with link_preview_options kwargs.pop("quote") # mutually exclusive with do_quote kwargs["link_preview_options"] = LinkPreviewOptions( diff --git a/tests/auxil/ci_bots.py b/tests/auxil/ci_bots.py index 903f4fa59..81e0c4819 100644 --- a/tests/auxil/ci_bots.py +++ b/tests/auxil/ci_bots.py @@ -24,6 +24,8 @@ import random from telegram._utils.strings import TextEncoding +from .envvars import GITHUB_ACTIONS + # Provide some public fallbacks so it's easy for contributors to run tests on their local machine # These bots are only able to talk in our test chats, so they are quite useless for other # purposes than testing. @@ -41,10 +43,9 @@ FALLBACKS = ( "NjcmlwdGlvbl9jaGFubmVsX2lkIjogLTEwMDIyMjk2NDkzMDN9XQ==" ) -GITHUB_ACTION = os.getenv("GITHUB_ACTION", None) BOTS = os.getenv("BOTS", None) JOB_INDEX = os.getenv("JOB_INDEX", None) -if GITHUB_ACTION is not None and BOTS is not None and JOB_INDEX is not None: +if GITHUB_ACTIONS and BOTS is not None and JOB_INDEX is not None: BOTS = json.loads(base64.b64decode(BOTS).decode(TextEncoding.UTF_8)) JOB_INDEX = int(JOB_INDEX) @@ -60,7 +61,7 @@ class BotInfoProvider: @staticmethod def _get_value(key, fallback): # If we're running as a github action then fetch bots from the repo secrets - if GITHUB_ACTION is not None and BOTS is not None and JOB_INDEX is not None: + if GITHUB_ACTIONS and BOTS is not None and JOB_INDEX is not None: try: return BOTS[JOB_INDEX][key] except (IndexError, KeyError): diff --git a/tests/auxil/envvars.py b/tests/auxil/envvars.py index 9d3665000..5fb2d20c8 100644 --- a/tests/auxil/envvars.py +++ b/tests/auxil/envvars.py @@ -27,6 +27,9 @@ def env_var_2_bool(env_var: object) -> bool: return env_var.lower().strip() == "true" -GITHUB_ACTION = os.getenv("GITHUB_ACTION", "") -TEST_WITH_OPT_DEPS = env_var_2_bool(os.getenv("TEST_WITH_OPT_DEPS", "true")) -RUN_TEST_OFFICIAL = env_var_2_bool(os.getenv("TEST_OFFICIAL")) +GITHUB_ACTIONS: bool = env_var_2_bool(os.getenv("GITHUB_ACTIONS", "false")) +TEST_WITH_OPT_DEPS: bool = env_var_2_bool(os.getenv("TEST_WITH_OPT_DEPS", "false")) or ( + # on local setups, we usually want to test with optional dependencies + not GITHUB_ACTIONS +) +RUN_TEST_OFFICIAL: bool = env_var_2_bool(os.getenv("TEST_OFFICIAL")) diff --git a/tests/conftest.py b/tests/conftest.py index 48965395e..e5e74a027 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,9 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio -import datetime as dtm import logging import sys +import zoneinfo from pathlib import Path from uuid import uuid4 @@ -40,11 +40,10 @@ from telegram.ext import Defaults from tests.auxil.build_messages import DATE from tests.auxil.ci_bots import BOT_INFO_PROVIDER, JOB_INDEX from tests.auxil.constants import PRIVATE_KEY, TEST_TOPIC_ICON_COLOR, TEST_TOPIC_NAME -from tests.auxil.envvars import GITHUB_ACTION, RUN_TEST_OFFICIAL, TEST_WITH_OPT_DEPS +from tests.auxil.envvars import GITHUB_ACTIONS, RUN_TEST_OFFICIAL, TEST_WITH_OPT_DEPS from tests.auxil.files import data_file from tests.auxil.networking import NonchalantHttpxRequest from tests.auxil.pytest_classes import PytestBot, make_bot -from tests.auxil.timezones import BasicTimezone if TEST_WITH_OPT_DEPS: import pytz @@ -97,7 +96,7 @@ def pytest_collection_modifyitems(items: list[pytest.Item]): parent.add_marker(pytest.mark.no_req) -if GITHUB_ACTION and JOB_INDEX == 0: +if GITHUB_ACTIONS and JOB_INDEX == 0: # let's not slow down the tests too much with these additional checks # that's why we run them only in GitHub actions and only on *one* of the several test # matrix entries @@ -308,12 +307,20 @@ def false_update(request): return Update(update_id=1, **request.param) +@pytest.fixture( + scope="session", + params=[pytz.timezone, zoneinfo.ZoneInfo] if TEST_WITH_OPT_DEPS else [zoneinfo.ZoneInfo], +) +def _tz_implementation(request): # noqa: PT005 + # This fixture is used to parametrize the timezone fixture + # This is similar to what @pyttest.mark.parametrize does but for fixtures + # However, this is needed only internally for the `tzinfo` fixture, so we keep it private + return request.param + + @pytest.fixture(scope="session", params=["Europe/Berlin", "Asia/Singapore", "UTC"]) -def tzinfo(request): - if TEST_WITH_OPT_DEPS: - return pytz.timezone(request.param) - hours_offset = {"Europe/Berlin": 2, "Asia/Singapore": 8, "UTC": 0}[request.param] - return BasicTimezone(offset=dtm.timedelta(hours=hours_offset), name=request.param) +def tzinfo(request, _tz_implementation): + return _tz_implementation(request.param) @pytest.fixture(scope="session") diff --git a/tests/ext/test_defaults.py b/tests/ext/test_defaults.py index 143b084a4..dc852aba1 100644 --- a/tests/ext/test_defaults.py +++ b/tests/ext/test_defaults.py @@ -38,10 +38,17 @@ class TestDefaults: def test_utc(self): defaults = Defaults() - if not TEST_WITH_OPT_DEPS: - assert defaults.tzinfo is dtm.timezone.utc - else: - assert defaults.tzinfo is not dtm.timezone.utc + assert defaults.tzinfo is dtm.timezone.utc + + @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="pytz not installed") + def test_pytz_deprecation(self, recwarn): + import pytz + + with pytest.warns(PTBDeprecationWarning, match="pytz") as record: + Defaults(tzinfo=pytz.timezone("Europe/Berlin")) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" def test_data_assignment(self): defaults = Defaults() diff --git a/tests/ext/test_jobqueue.py b/tests/ext/test_jobqueue.py index 3de60cfbc..7af0040d6 100644 --- a/tests/ext/test_jobqueue.py +++ b/tests/ext/test_jobqueue.py @@ -18,6 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio import calendar +import contextlib import datetime as dtm import logging import platform @@ -26,7 +27,7 @@ import time import pytest from telegram.ext import ApplicationBuilder, CallbackContext, ContextTypes, Defaults, Job, JobQueue -from tests.auxil.envvars import GITHUB_ACTION, TEST_WITH_OPT_DEPS +from tests.auxil.envvars import GITHUB_ACTIONS, TEST_WITH_OPT_DEPS from tests.auxil.pytest_classes import make_bot from tests.auxil.slots import mro_slots @@ -68,7 +69,7 @@ class TestNoJobQueue: not TEST_WITH_OPT_DEPS, reason="Only relevant if the optional dependency is installed" ) @pytest.mark.skipif( - bool(GITHUB_ACTION and platform.system() in ["Windows", "Darwin"]), + GITHUB_ACTIONS and platform.system() in ["Windows", "Darwin"], reason="On Windows & MacOS precise timings are not accurate.", ) @pytest.mark.flaky(10, 1) # Timings aren't quite perfect @@ -77,6 +78,13 @@ class TestJobQueue: job_time = 0 received_error = None + @staticmethod + def normalize(datetime: dtm.datetime, timezone: dtm.tzinfo) -> dtm.datetime: + with contextlib.suppress(AttributeError): + return timezone.normalize(datetime) + + return datetime + async def test_repr(self, app): jq = JobQueue() jq.set_application(app) @@ -102,7 +110,7 @@ class TestJobQueue: # Unfortunately, we can't really test the executor setting explicitly without relying # on protected attributes. However, this should be tested enough implicitly via all the # other tests in here - assert job_queue.scheduler_configuration["timezone"] is UTC + assert job_queue.scheduler_configuration["timezone"] is dtm.timezone.utc tz_app = ApplicationBuilder().defaults(Defaults(tzinfo=timezone)).token(bot.token).build() assert tz_app.job_queue.scheduler_configuration["timezone"] is timezone @@ -356,6 +364,17 @@ class TestJobQueue: scheduled_time = job_queue.jobs()[0].next_t.timestamp() assert scheduled_time == pytest.approx(expected_time) + async def test_time_unit_dt_aware_time(self, job_queue, timezone): + # Testing running at a specific tz-aware time today + delta, now = 0.5, dtm.datetime.now(timezone) + expected_time = now + dtm.timedelta(seconds=delta) + when = expected_time.timetz() + expected_time = expected_time.timestamp() + + job_queue.run_once(self.job_datetime_tests, when) + await asyncio.sleep(0.6) + assert self.job_time == pytest.approx(expected_time) + async def test_run_daily(self, job_queue): delta, now = 1, dtm.datetime.now(UTC) time_of_day = (now + dtm.timedelta(seconds=delta)).time() @@ -397,7 +416,7 @@ class TestJobQueue: if day > next_months_days: expected_reschedule_time += dtm.timedelta(next_months_days) - expected_reschedule_time = timezone.normalize(expected_reschedule_time) + expected_reschedule_time = self.normalize(expected_reschedule_time, timezone) # Adjust the hour for the special case that between now and next month a DST switch happens expected_reschedule_time += dtm.timedelta( hours=time_of_day.hour - expected_reschedule_time.hour @@ -419,7 +438,7 @@ class TestJobQueue: calendar.monthrange(now.year, now.month)[1] ) - dtm.timedelta(days=now.day) # Adjust the hour for the special case that between now & end of month a DST switch happens - expected_reschedule_time = timezone.normalize(expected_reschedule_time) + expected_reschedule_time = self.normalize(expected_reschedule_time, timezone) expected_reschedule_time += dtm.timedelta( hours=time_of_day.hour - expected_reschedule_time.hour ) diff --git a/tests/ext/test_ratelimiter.py b/tests/ext/test_ratelimiter.py index 0b8a8c6b8..dd88f7cb1 100644 --- a/tests/ext/test_ratelimiter.py +++ b/tests/ext/test_ratelimiter.py @@ -35,7 +35,7 @@ from telegram.constants import ParseMode from telegram.error import RetryAfter from telegram.ext import AIORateLimiter, BaseRateLimiter, Defaults, ExtBot from telegram.request import BaseRequest, RequestData -from tests.auxil.envvars import GITHUB_ACTION, TEST_WITH_OPT_DEPS +from tests.auxil.envvars import GITHUB_ACTIONS, TEST_WITH_OPT_DEPS @pytest.mark.skipif( @@ -142,7 +142,7 @@ class TestBaseRateLimiter: not TEST_WITH_OPT_DEPS, reason="Only relevant if the optional dependency is installed" ) @pytest.mark.skipif( - bool(GITHUB_ACTION and platform.system() == "Darwin"), + GITHUB_ACTIONS and platform.system() == "Darwin", reason="The timings are apparently rather inaccurate on MacOS.", ) @pytest.mark.flaky(10, 1) # Timings aren't quite perfect diff --git a/tests/test_bot.py b/tests/test_bot.py index 985b2b507..65603dbda 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -79,7 +79,7 @@ from telegram import ( User, WebAppInfo, ) -from telegram._utils.datetime import UTC, from_timestamp, to_timestamp +from telegram._utils.datetime import UTC, from_timestamp, localize, to_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.strings import to_camel_case from telegram.constants import ( @@ -97,7 +97,7 @@ from telegram.request import BaseRequest, HTTPXRequest, RequestData from telegram.warnings import PTBDeprecationWarning, PTBUserWarning from tests.auxil.bot_method_checks import check_defaults_handling from tests.auxil.ci_bots import FALLBACKS -from tests.auxil.envvars import GITHUB_ACTION, TEST_WITH_OPT_DEPS +from tests.auxil.envvars import GITHUB_ACTIONS from tests.auxil.files import data_file from tests.auxil.networking import OfflineRequest, expect_bad_request from tests.auxil.pytest_classes import PytestBot, PytestExtBot, make_bot @@ -154,7 +154,7 @@ def inline_results(): BASE_GAME_SCORE = 60 # Base game score for game tests xfail = pytest.mark.xfail( - bool(GITHUB_ACTION), # This condition is only relevant for github actions game tests. + GITHUB_ACTIONS, # This condition is only relevant for github actions game tests. reason=( "Can fail due to race conditions when multiple test suites " "with the same bot token are run at the same time" @@ -3467,7 +3467,6 @@ class TestBotWithRequest: ) assert revoked_link.is_revoked - @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="This test's implementation requires pytz") @pytest.mark.parametrize("datetime", argvalues=[True, False], ids=["datetime", "integer"]) async def test_advanced_chat_invite_links(self, bot, channel_id, datetime): # we are testing this all in one function in order to save api calls @@ -3475,7 +3474,7 @@ class TestBotWithRequest: add_seconds = dtm.timedelta(0, 70) time_in_future = timestamp + add_seconds expire_time = time_in_future if datetime else to_timestamp(time_in_future) - aware_time_in_future = UTC.localize(time_in_future) + aware_time_in_future = localize(time_in_future, UTC) invite_link = await bot.create_chat_invite_link( channel_id, expire_date=expire_time, member_limit=10 @@ -3488,7 +3487,7 @@ class TestBotWithRequest: add_seconds = dtm.timedelta(0, 80) time_in_future = timestamp + add_seconds expire_time = time_in_future if datetime else to_timestamp(time_in_future) - aware_time_in_future = UTC.localize(time_in_future) + aware_time_in_future = localize(time_in_future, UTC) edited_invite_link = await bot.edit_chat_invite_link( channel_id, diff --git a/tests/test_constants.py b/tests/test_constants.py index dc352a42f..3cd9e56e7 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -58,7 +58,7 @@ class TestConstantsWithoutRequest: not key.startswith("_") # exclude imported stuff and getattr(member, "__module__", "telegram.constants") == "telegram.constants" - and key not in ("sys", "dtm") + and key not in ("sys", "dtm", "UTC") ) } actual = set(constants.__all__)