Bump APS & Deprecate pytz Support (#4582)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com>
This commit is contained in:
dependabot[bot] 2025-01-01 19:43:52 +01:00 committed by GitHub
parent 5f35304e63
commit d0a6e5141c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 190 additions and 99 deletions

View file

@ -64,7 +64,8 @@ jobs:
# Test the rest # Test the rest
export TEST_WITH_OPT_DEPS='true' 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 # `-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 # workers. Increasing number of workers has little effect on test duration, but it seems
# to increase flakyness. # to increase flakyness.

View file

@ -158,7 +158,7 @@ PTB can be installed with optional dependencies:
* ``pip install "python-telegram-bot[rate-limiter]"`` installs `aiolimiter~=1.1,<1.3 <https://aiolimiter.readthedocs.io/en/stable/>`_. Use this, if you want to use ``telegram.ext.AIORateLimiter``. * ``pip install "python-telegram-bot[rate-limiter]"`` installs `aiolimiter~=1.1,<1.3 <https://aiolimiter.readthedocs.io/en/stable/>`_. Use this, if you want to use ``telegram.ext.AIORateLimiter``.
* ``pip install "python-telegram-bot[webhooks]"`` installs the `tornado~=6.4 <https://www.tornadoweb.org/en/stable/>`_ library. Use this, if you want to use ``telegram.ext.Updater.start_webhook``/``telegram.ext.Application.run_webhook``. * ``pip install "python-telegram-bot[webhooks]"`` installs the `tornado~=6.4 <https://www.tornadoweb.org/en/stable/>`_ 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 <https://cachetools.readthedocs.io/en/latest/>`_ library. Use this, if you want to use `arbitrary callback_data <https://github.com/python-telegram-bot/python-telegram-bot/wiki/Arbitrary-callback_data>`_. * ``pip install "python-telegram-bot[callback-data]"`` installs the `cachetools>=5.3.3,<5.6.0 <https://cachetools.readthedocs.io/en/latest/>`_ library. Use this, if you want to use `arbitrary callback_data <https://github.com/python-telegram-bot/python-telegram-bot/wiki/Arbitrary-callback_data>`_.
* ``pip install "python-telegram-bot[job-queue]"`` installs the `APScheduler~=3.10.4 <https://apscheduler.readthedocs.io/en/3.x/>`_ library and enforces `pytz>=2018.6 <https://pypi.org/project/pytz/>`_, 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 <https://apscheduler.readthedocs.io/en/3.x/>`_ 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]"``. To install multiple optional dependencies, separate them by commas, e.g. ``pip install "python-telegram-bot[socks,webhooks]"``.

View file

@ -76,9 +76,7 @@ http2 = [
] ]
job-queue = [ job-queue = [
# APS doesn't have a strict stability policy. Let's be cautious for now. # APS doesn't have a strict stability policy. Let's be cautious for now.
"APScheduler~=3.10.4", "APScheduler>=3.10.4,<3.12.0",
# pytz is required by APS and just needs the lower bound due to #2120
"pytz>=2018.6",
] ]
passport = [ passport = [
"cryptography!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=39.0.1", "cryptography!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=39.0.1",

View file

@ -16,4 +16,8 @@ pytest-xdist==3.6.1
flaky>=3.8.1 flaky>=3.8.1
# used in test_official for parsing tg docs # used in test_official for parsing tg docs
beautifulsoup4 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

View file

@ -27,6 +27,7 @@ Warning:
user. Changes to this module are not considered breaking changes and may not be documented in user. Changes to this module are not considered breaking changes and may not be documented in
the changelog. the changelog.
""" """
import contextlib
import datetime as dtm import datetime as dtm
import time import time
from typing import TYPE_CHECKING, Optional, Union from typing import TYPE_CHECKING, Optional, Union
@ -34,22 +35,26 @@ from typing import TYPE_CHECKING, Optional, Union
if TYPE_CHECKING: if TYPE_CHECKING:
from telegram import Bot from telegram import Bot
# pytz is only available if it was installed as dependency of APScheduler, so we make a little UTC = dtm.timezone.utc
# workaround here
DTM_UTC = dtm.timezone.utc
try: try:
import pytz import pytz
UTC = pytz.utc
except ImportError: except ImportError:
UTC = DTM_UTC # type: ignore[assignment] pytz = None # type: ignore[assignment]
def _localize(datetime: dtm.datetime, tzinfo: dtm.tzinfo) -> dtm.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""" """Localize the datetime, both for pytz and zoneinfo timezones."""
if tzinfo is DTM_UTC: if tzinfo is UTC:
return datetime.replace(tzinfo=DTM_UTC) return datetime.replace(tzinfo=UTC)
return tzinfo.localize(datetime) # type: ignore[attr-defined]
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( def to_float_timestamp(
@ -87,7 +92,7 @@ def to_float_timestamp(
will be raised. will be raised.
tzinfo (:class:`datetime.tzinfo`, optional): If :paramref:`time_object` is a naive object 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 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: Note:
Only to be used by ``telegram.ext``. Only to be used by ``telegram.ext``.
@ -121,6 +126,12 @@ def to_float_timestamp(
return reference_timestamp + time_object return reference_timestamp + time_object
if tzinfo is None: 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 tzinfo = UTC
if isinstance(time_object, dtm.time): if isinstance(time_object, dtm.time):
@ -132,7 +143,9 @@ def to_float_timestamp(
aware_datetime = dtm.datetime.combine(reference_date, time_object) aware_datetime = dtm.datetime.combine(reference_date, time_object)
if aware_datetime.tzinfo is None: 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 the time of day has passed today, use tomorrow
if reference_time > aware_datetime.timetz(): if reference_time > aware_datetime.timetz():
@ -140,7 +153,7 @@ def to_float_timestamp(
return _datetime_to_float_timestamp(aware_datetime) return _datetime_to_float_timestamp(aware_datetime)
if isinstance(time_object, dtm.datetime): if isinstance(time_object, dtm.datetime):
if time_object.tzinfo is None: 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) return _datetime_to_float_timestamp(time_object)
raise TypeError(f"Unable to convert {type(time_object).__name__} object to timestamp") raise TypeError(f"Unable to convert {type(time_object).__name__} object to timestamp")

View file

@ -57,10 +57,13 @@ class Defaults:
versions. versions.
tzinfo (:class:`datetime.tzinfo`, optional): A timezone to be used for all date(time) 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 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 somewhere, it will be assumed to be in :paramref:`tzinfo`. Defaults to
:class:`telegram.ext.JobQueue` is used, this must be a timezone provided
by the ``pytz`` module. Defaults to ``pytz.utc``, if available, and
:attr:`datetime.timezone.utc` otherwise. :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` block (:obj:`bool`, optional): Default setting for the :paramref:`BaseHandler.block`
parameter parameter
of handlers and error handlers registered through :meth:`Application.add_handler` and of handlers and error handlers registered through :meth:`Application.add_handler` and
@ -148,6 +151,19 @@ class Defaults:
self._block: bool = block self._block: bool = block
self._protect_content: Optional[bool] = protect_content 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: if disable_web_page_preview is not None and link_preview_options is not None:
raise ValueError( raise ValueError(
"`disable_web_page_preview` and `link_preview_options` are mutually exclusive." "`disable_web_page_preview` and `link_preview_options` are mutually exclusive."

View file

@ -23,7 +23,6 @@ import weakref
from typing import TYPE_CHECKING, Any, Generic, Optional, Union, cast, overload from typing import TYPE_CHECKING, Any, Generic, Optional, Union, cast, overload
try: try:
import pytz
from apscheduler.executors.asyncio import AsyncIOExecutor from apscheduler.executors.asyncio import AsyncIOExecutor
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
@ -31,6 +30,7 @@ try:
except ImportError: except ImportError:
APS_AVAILABLE = False APS_AVAILABLE = False
from telegram._utils.datetime import UTC, localize
from telegram._utils.logging import get_logger from telegram._utils.logging import get_logger
from telegram._utils.repr import build_repr_with_selected_attrs from telegram._utils.repr import build_repr_with_selected_attrs
from telegram._utils.types import JSONDict from telegram._utils.types import JSONDict
@ -155,13 +155,13 @@ class JobQueue(Generic[CCT]):
dict[:obj:`str`, :obj:`object`]: The configuration values as dictionary. dict[:obj:`str`, :obj:`object`]: The configuration values as dictionary.
""" """
timezone: object = pytz.utc timezone: dtm.tzinfo = UTC
if ( if (
self._application self._application
and isinstance(self.application.bot, ExtBot) and isinstance(self.application.bot, ExtBot)
and self.application.bot.defaults and self.application.bot.defaults
): ):
timezone = self.application.bot.defaults.tzinfo or pytz.utc timezone = self.application.bot.defaults.tzinfo or UTC
return { return {
"timezone": timezone, "timezone": timezone,
@ -197,8 +197,10 @@ class JobQueue(Generic[CCT]):
dtm.datetime.now(tz=time.tzinfo or self.scheduler.timezone).date(), time dtm.datetime.now(tz=time.tzinfo or self.scheduler.timezone).date(), time
) )
if date_time.tzinfo is None: if date_time.tzinfo is None:
date_time = self.scheduler.timezone.localize(date_time) # dtm.combine uses the tzinfo of `time`, which might be None, so we still have
if shift_day and date_time <= dtm.datetime.now(pytz.utc): # 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) date_time += dtm.timedelta(days=1)
return date_time return date_time
return time return time

View file

@ -18,6 +18,7 @@
# along with this program. If not, see [http://www.gnu.org/licenses/]. # along with this program. If not, see [http://www.gnu.org/licenses/].
import datetime as dtm import datetime as dtm
import time import time
import zoneinfo
import pytest import pytest
@ -55,18 +56,38 @@ with the TEST_WITH_OPT_DEPS=False environment variable in addition to the regula
class TestDatetime: class TestDatetime:
@staticmethod def test_localize_utc(self):
def localize(dt, tzinfo): dt = dtm.datetime(2023, 1, 1, 12, 0, 0)
if TEST_WITH_OPT_DEPS: localized_dt = tg_dtm.localize(dt, tg_dtm.UTC)
return tzinfo.localize(dt) assert localized_dt.tzinfo == tg_dtm.UTC
return dt.replace(tzinfo=tzinfo) assert localized_dt == dt.replace(tzinfo=tg_dtm.UTC)
def test_helpers_utc(self): @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="pytz not installed")
# Here we just test, that we got the correct UTC variant def test_localize_pytz(self):
if not TEST_WITH_OPT_DEPS: dt = dtm.datetime(2023, 1, 1, 12, 0, 0)
assert tg_dtm.UTC is tg_dtm.DTM_UTC import pytz
else:
assert tg_dtm.UTC is not tg_dtm.DTM_UTC 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): def test_to_float_timestamp_absolute_naive(self):
"""Conversion from timezone-naive datetime to timestamp. """Conversion from timezone-naive datetime to timestamp.
@ -75,20 +96,12 @@ class TestDatetime:
datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5) datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5)
assert tg_dtm.to_float_timestamp(datetime) == 1573431976.1 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): def test_to_float_timestamp_absolute_aware(self, timezone):
"""Conversion from timezone-aware datetime to timestamp""" """Conversion from timezone-aware datetime to timestamp"""
# we're parametrizing this with two different UTC offsets to exclude the possibility # 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 # 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) 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 ( assert (
tg_dtm.to_float_timestamp(datetime) tg_dtm.to_float_timestamp(datetime)
== 1573431976.1 - timezone.utcoffset(test_datetime).total_seconds() == 1573431976.1 - timezone.utcoffset(test_datetime).total_seconds()
@ -126,7 +139,7 @@ class TestDatetime:
ref_datetime = dtm.datetime(1970, 1, 1, 12) ref_datetime = dtm.datetime(1970, 1, 1, 12)
utc_offset = timezone.utcoffset(ref_datetime) utc_offset = timezone.utcoffset(ref_datetime)
ref_t, time_of_day = tg_dtm._datetime_to_float_timestamp(ref_datetime), ref_datetime.time() 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: # 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) 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 # 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 # 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) 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 ( assert (
tg_dtm.from_timestamp(1573431976.1 - timezone.utcoffset(test_datetime).total_seconds()) tg_dtm.from_timestamp(1573431976.1 - timezone.utcoffset(test_datetime).total_seconds())
== datetime == datetime

View file

@ -21,6 +21,7 @@ import datetime as dtm
import functools import functools
import inspect import inspect
import re import re
import zoneinfo
from collections.abc import Collection, Iterable from collections.abc import Collection, Iterable
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
@ -40,15 +41,11 @@ from telegram import (
Sticker, Sticker,
TelegramObject, TelegramObject,
) )
from telegram._utils.datetime import to_timestamp
from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue
from telegram.constants import InputMediaType from telegram.constants import InputMediaType
from telegram.ext import Defaults, ExtBot from telegram.ext import Defaults, ExtBot
from telegram.request import RequestData 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<class_name>\w+)'\)") FORWARD_REF_PATTERN = re.compile(r"ForwardRef\('(?P<class_name>\w+)'\)")
""" A pattern to find a class name in a ForwardRef typing annotation. """ 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 # Some special casing for methods that have "exactly one of the optionals" type args
elif name in ["location", "contact", "venue", "inline_message_id"]: elif name in ["location", "contact", "venue", "inline_message_id"]:
kws[name] = True kws[name] = True
elif name == "until_date": elif name.endswith("_date"):
if manually_passed_value not in [None, DEFAULT_NONE]: if manually_passed_value not in [None, DEFAULT_NONE]:
# Europe/Berlin # 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: else:
# naive UTC # naive UTC
kws[name] = dtm.datetime(2000, 1, 1, 0) 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( async def make_assertion(
url, url,
request_data: RequestData, request_data: RequestData,
@ -530,14 +536,16 @@ async def make_assertion(
) )
# Check datetime conversion # Check datetime conversion
until_date = data.pop("until_date", None) date_keys = [key for key in data if key.endswith("_date")]
if until_date: for key in date_keys:
if manual_value_expected and until_date != 946681200: date_param = data.pop(key)
pytest.fail("Non-naive until_date should have been interpreted as Europe/Berlin.") if date_param:
if not any((manually_passed_value, expected_defaults_value)) and until_date != 946684800: if manual_value_expected and date_param != _EUROPE_BERLIN_TS:
pytest.fail("Naive until_date should have been interpreted as UTC") pytest.fail(f"Non-naive `{key}` should have been interpreted as Europe/Berlin.")
if default_value_expected and until_date != 946702800: if not any((manually_passed_value, expected_defaults_value)) and date_param != _UTC_TS:
pytest.fail("Naive until_date should have been interpreted as America/New_York") 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"]: 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 # 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() defaults_no_custom_defaults = Defaults()
kwargs = {kwarg: "custom_default" for kwarg in inspect.signature(Defaults).parameters} 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("disable_web_page_preview") # mutually exclusive with link_preview_options
kwargs.pop("quote") # mutually exclusive with do_quote kwargs.pop("quote") # mutually exclusive with do_quote
kwargs["link_preview_options"] = LinkPreviewOptions( kwargs["link_preview_options"] = LinkPreviewOptions(

View file

@ -24,6 +24,8 @@ import random
from telegram._utils.strings import TextEncoding 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 # 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 # These bots are only able to talk in our test chats, so they are quite useless for other
# purposes than testing. # purposes than testing.
@ -41,10 +43,9 @@ FALLBACKS = (
"NjcmlwdGlvbl9jaGFubmVsX2lkIjogLTEwMDIyMjk2NDkzMDN9XQ==" "NjcmlwdGlvbl9jaGFubmVsX2lkIjogLTEwMDIyMjk2NDkzMDN9XQ=="
) )
GITHUB_ACTION = os.getenv("GITHUB_ACTION", None)
BOTS = os.getenv("BOTS", None) BOTS = os.getenv("BOTS", None)
JOB_INDEX = os.getenv("JOB_INDEX", 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)) BOTS = json.loads(base64.b64decode(BOTS).decode(TextEncoding.UTF_8))
JOB_INDEX = int(JOB_INDEX) JOB_INDEX = int(JOB_INDEX)
@ -60,7 +61,7 @@ class BotInfoProvider:
@staticmethod @staticmethod
def _get_value(key, fallback): def _get_value(key, fallback):
# If we're running as a github action then fetch bots from the repo secrets # 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: try:
return BOTS[JOB_INDEX][key] return BOTS[JOB_INDEX][key]
except (IndexError, KeyError): except (IndexError, KeyError):

View file

@ -27,6 +27,9 @@ def env_var_2_bool(env_var: object) -> bool:
return env_var.lower().strip() == "true" return env_var.lower().strip() == "true"
GITHUB_ACTION = os.getenv("GITHUB_ACTION", "") GITHUB_ACTIONS: bool = env_var_2_bool(os.getenv("GITHUB_ACTIONS", "false"))
TEST_WITH_OPT_DEPS = env_var_2_bool(os.getenv("TEST_WITH_OPT_DEPS", "true")) TEST_WITH_OPT_DEPS: bool = env_var_2_bool(os.getenv("TEST_WITH_OPT_DEPS", "false")) or (
RUN_TEST_OFFICIAL = env_var_2_bool(os.getenv("TEST_OFFICIAL")) # 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"))

View file

@ -17,9 +17,9 @@
# You should have received a copy of the GNU Lesser Public License # You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/]. # along with this program. If not, see [http://www.gnu.org/licenses/].
import asyncio import asyncio
import datetime as dtm
import logging import logging
import sys import sys
import zoneinfo
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
@ -40,11 +40,10 @@ from telegram.ext import Defaults
from tests.auxil.build_messages import DATE from tests.auxil.build_messages import DATE
from tests.auxil.ci_bots import BOT_INFO_PROVIDER, JOB_INDEX 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.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.files import data_file
from tests.auxil.networking import NonchalantHttpxRequest from tests.auxil.networking import NonchalantHttpxRequest
from tests.auxil.pytest_classes import PytestBot, make_bot from tests.auxil.pytest_classes import PytestBot, make_bot
from tests.auxil.timezones import BasicTimezone
if TEST_WITH_OPT_DEPS: if TEST_WITH_OPT_DEPS:
import pytz import pytz
@ -97,7 +96,7 @@ def pytest_collection_modifyitems(items: list[pytest.Item]):
parent.add_marker(pytest.mark.no_req) 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 # 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 # that's why we run them only in GitHub actions and only on *one* of the several test
# matrix entries # matrix entries
@ -308,12 +307,20 @@ def false_update(request):
return Update(update_id=1, **request.param) 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"]) @pytest.fixture(scope="session", params=["Europe/Berlin", "Asia/Singapore", "UTC"])
def tzinfo(request): def tzinfo(request, _tz_implementation):
if TEST_WITH_OPT_DEPS: return _tz_implementation(request.param)
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)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")

View file

@ -38,10 +38,17 @@ class TestDefaults:
def test_utc(self): def test_utc(self):
defaults = Defaults() defaults = Defaults()
if not TEST_WITH_OPT_DEPS: assert defaults.tzinfo is dtm.timezone.utc
assert defaults.tzinfo is dtm.timezone.utc
else: @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="pytz not installed")
assert defaults.tzinfo is not dtm.timezone.utc 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): def test_data_assignment(self):
defaults = Defaults() defaults = Defaults()

View file

@ -18,6 +18,7 @@
# along with this program. If not, see [http://www.gnu.org/licenses/]. # along with this program. If not, see [http://www.gnu.org/licenses/].
import asyncio import asyncio
import calendar import calendar
import contextlib
import datetime as dtm import datetime as dtm
import logging import logging
import platform import platform
@ -26,7 +27,7 @@ import time
import pytest import pytest
from telegram.ext import ApplicationBuilder, CallbackContext, ContextTypes, Defaults, Job, JobQueue 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.pytest_classes import make_bot
from tests.auxil.slots import mro_slots 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" not TEST_WITH_OPT_DEPS, reason="Only relevant if the optional dependency is installed"
) )
@pytest.mark.skipif( @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.", reason="On Windows & MacOS precise timings are not accurate.",
) )
@pytest.mark.flaky(10, 1) # Timings aren't quite perfect @pytest.mark.flaky(10, 1) # Timings aren't quite perfect
@ -77,6 +78,13 @@ class TestJobQueue:
job_time = 0 job_time = 0
received_error = None 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): async def test_repr(self, app):
jq = JobQueue() jq = JobQueue()
jq.set_application(app) jq.set_application(app)
@ -102,7 +110,7 @@ class TestJobQueue:
# Unfortunately, we can't really test the executor setting explicitly without relying # 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 # on protected attributes. However, this should be tested enough implicitly via all the
# other tests in here # 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() tz_app = ApplicationBuilder().defaults(Defaults(tzinfo=timezone)).token(bot.token).build()
assert tz_app.job_queue.scheduler_configuration["timezone"] is timezone 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() scheduled_time = job_queue.jobs()[0].next_t.timestamp()
assert scheduled_time == pytest.approx(expected_time) 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): async def test_run_daily(self, job_queue):
delta, now = 1, dtm.datetime.now(UTC) delta, now = 1, dtm.datetime.now(UTC)
time_of_day = (now + dtm.timedelta(seconds=delta)).time() time_of_day = (now + dtm.timedelta(seconds=delta)).time()
@ -397,7 +416,7 @@ class TestJobQueue:
if day > next_months_days: if day > next_months_days:
expected_reschedule_time += dtm.timedelta(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 # Adjust the hour for the special case that between now and next month a DST switch happens
expected_reschedule_time += dtm.timedelta( expected_reschedule_time += dtm.timedelta(
hours=time_of_day.hour - expected_reschedule_time.hour hours=time_of_day.hour - expected_reschedule_time.hour
@ -419,7 +438,7 @@ class TestJobQueue:
calendar.monthrange(now.year, now.month)[1] calendar.monthrange(now.year, now.month)[1]
) - dtm.timedelta(days=now.day) ) - dtm.timedelta(days=now.day)
# Adjust the hour for the special case that between now & end of month a DST switch happens # 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( expected_reschedule_time += dtm.timedelta(
hours=time_of_day.hour - expected_reschedule_time.hour hours=time_of_day.hour - expected_reschedule_time.hour
) )

View file

@ -35,7 +35,7 @@ from telegram.constants import ParseMode
from telegram.error import RetryAfter from telegram.error import RetryAfter
from telegram.ext import AIORateLimiter, BaseRateLimiter, Defaults, ExtBot from telegram.ext import AIORateLimiter, BaseRateLimiter, Defaults, ExtBot
from telegram.request import BaseRequest, RequestData 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( @pytest.mark.skipif(
@ -142,7 +142,7 @@ class TestBaseRateLimiter:
not TEST_WITH_OPT_DEPS, reason="Only relevant if the optional dependency is installed" not TEST_WITH_OPT_DEPS, reason="Only relevant if the optional dependency is installed"
) )
@pytest.mark.skipif( @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.", reason="The timings are apparently rather inaccurate on MacOS.",
) )
@pytest.mark.flaky(10, 1) # Timings aren't quite perfect @pytest.mark.flaky(10, 1) # Timings aren't quite perfect

View file

@ -79,7 +79,7 @@ from telegram import (
User, User,
WebAppInfo, 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.defaultvalue import DEFAULT_NONE
from telegram._utils.strings import to_camel_case from telegram._utils.strings import to_camel_case
from telegram.constants import ( from telegram.constants import (
@ -97,7 +97,7 @@ from telegram.request import BaseRequest, HTTPXRequest, RequestData
from telegram.warnings import PTBDeprecationWarning, PTBUserWarning from telegram.warnings import PTBDeprecationWarning, PTBUserWarning
from tests.auxil.bot_method_checks import check_defaults_handling from tests.auxil.bot_method_checks import check_defaults_handling
from tests.auxil.ci_bots import FALLBACKS 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.files import data_file
from tests.auxil.networking import OfflineRequest, expect_bad_request from tests.auxil.networking import OfflineRequest, expect_bad_request
from tests.auxil.pytest_classes import PytestBot, PytestExtBot, make_bot 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 BASE_GAME_SCORE = 60 # Base game score for game tests
xfail = pytest.mark.xfail( 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=( reason=(
"Can fail due to race conditions when multiple test suites " "Can fail due to race conditions when multiple test suites "
"with the same bot token are run at the same time" "with the same bot token are run at the same time"
@ -3467,7 +3467,6 @@ class TestBotWithRequest:
) )
assert revoked_link.is_revoked 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"]) @pytest.mark.parametrize("datetime", argvalues=[True, False], ids=["datetime", "integer"])
async def test_advanced_chat_invite_links(self, bot, channel_id, datetime): 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 # 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) add_seconds = dtm.timedelta(0, 70)
time_in_future = timestamp + add_seconds time_in_future = timestamp + add_seconds
expire_time = time_in_future if datetime else to_timestamp(time_in_future) 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( invite_link = await bot.create_chat_invite_link(
channel_id, expire_date=expire_time, member_limit=10 channel_id, expire_date=expire_time, member_limit=10
@ -3488,7 +3487,7 @@ class TestBotWithRequest:
add_seconds = dtm.timedelta(0, 80) add_seconds = dtm.timedelta(0, 80)
time_in_future = timestamp + add_seconds time_in_future = timestamp + add_seconds
expire_time = time_in_future if datetime else to_timestamp(time_in_future) 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( edited_invite_link = await bot.edit_chat_invite_link(
channel_id, channel_id,

View file

@ -58,7 +58,7 @@ class TestConstantsWithoutRequest:
not key.startswith("_") not key.startswith("_")
# exclude imported stuff # exclude imported stuff
and getattr(member, "__module__", "telegram.constants") == "telegram.constants" 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__) actual = set(constants.__all__)