diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml
index 7503d2370df..e6fd437c663 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 3721a834fd0..f93c1d8c93e 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 6fba965299d..01406663f55 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 654bc9e9fdb..a9c4ba3c2c9 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 8a6b158b9f1..8e6ebdda1b4 100644
--- a/telegram/_utils/datetime.py
+++ b/telegram/_utils/datetime.py
@@ -27,6 +27,7 @@
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 @@
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, 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)
-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]
+ 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 69b870603db..357e379cf04 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 @@ def __init__(
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 823d6c80cc1..14a005f8428 100644
--- a/telegram/ext/_jobqueue.py
+++ b/telegram/ext/_jobqueue.py
@@ -23,7 +23,6 @@
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 @@
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 @@ def scheduler_configuration(self) -> JSONDict:
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 @@ def _parse_time_input(
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 d5f138a2ead..dfcaca67587 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 @@
class TestDatetime:
- @staticmethod
- def localize(dt, tzinfo):
- if TEST_WITH_OPT_DEPS:
- return tzinfo.localize(dt)
- return dt.replace(tzinfo=tzinfo)
-
- 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
+ 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)
+
+ @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 @@ def test_to_float_timestamp_absolute_naive(self):
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 @@ def test_to_float_timestamp_time_of_day_timezone(self, timezone):
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 @@ def test_from_timestamp_aware(self, timezone):
# 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 5cc760351e6..7e50a8dae85 100644
--- a/tests/auxil/bot_method_checks.py
+++ b/tests/auxil/bot_method_checks.py
@@ -21,6 +21,7 @@
import functools
import inspect
import re
+import zoneinfo
from collections.abc import Collection, Iterable
from typing import Any, Callable, Optional
@@ -40,15 +41,11 @@
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 @@ def check_input_media(m: dict):
)
# 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 903f4fa5979..81e0c4819b8 100644
--- a/tests/auxil/ci_bots.py
+++ b/tests/auxil/ci_bots.py
@@ -24,6 +24,8 @@
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 @@
"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 @@ def __init__(self):
@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 9d3665000a0..5fb2d20c8a1 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 48965395e19..e5e74a0271b 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 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 143b084a491..dc852aba19f 100644
--- a/tests/ext/test_defaults.py
+++ b/tests/ext/test_defaults.py
@@ -38,10 +38,17 @@ def test_slot_behaviour(self):
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 3de60cfbcfc..7af0040d632 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 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 @@ def test_init_job(self):
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 @@ def test_scheduler_configuration(self, job_queue, timezone, bot):
# 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 @@ async def test_time_unit_dt_time_tomorrow(self, job_queue):
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 @@ async def test_run_monthly(self, job_queue, timezone):
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 @@ async def test_run_monthly_non_strict_day(self, job_queue, timezone):
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 0b8a8c6b8ed..dd88f7cb1cf 100644
--- a/tests/ext/test_ratelimiter.py
+++ b/tests/ext/test_ratelimiter.py
@@ -35,7 +35,7 @@
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 @@ async def do_request(self, *args, **kwargs):
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 985b2b5078d..65603dbda97 100644
--- a/tests/test_bot.py
+++ b/tests/test_bot.py
@@ -79,7 +79,7 @@
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.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 @@ async def test_create_chat_invite_link_basics(
)
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 @@ async def test_advanced_chat_invite_links(self, bot, channel_id, datetime):
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 @@ async def test_advanced_chat_invite_links(self, bot, channel_id, datetime):
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 dc352a42f98..3cd9e56e7ab 100644
--- a/tests/test_constants.py
+++ b/tests/test_constants.py
@@ -58,7 +58,7 @@ def test__all__(self):
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__)