Skip to content

Commit

Permalink
Bump APS & Deprecate pytz Support (python-telegram-bot#4582)
Browse files Browse the repository at this point in the history
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Hinrich Mahler <[email protected]>
  • Loading branch information
dependabot[bot] and Bibo-Joshi authored Jan 1, 2025
1 parent 5f35304 commit d0a6e51
Show file tree
Hide file tree
Showing 17 changed files with 191 additions and 100 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/unit_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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[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[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]"``.

Expand Down
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion requirements-unit-tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ pytest-xdist==3.6.1
flaky>=3.8.1

# 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
41 changes: 27 additions & 14 deletions telegram/_utils/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,29 +27,34 @@
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

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(
Expand Down Expand Up @@ -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``.
Expand Down Expand Up @@ -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):
Expand All @@ -132,15 +143,17 @@ 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():
aware_datetime += dtm.timedelta(days=1)
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")
Expand Down
22 changes: 19 additions & 3 deletions telegram/ext/_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."
Expand Down
12 changes: 7 additions & 5 deletions telegram/ext/_jobqueue.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@
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

APS_AVAILABLE = True
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
59 changes: 36 additions & 23 deletions tests/_utils/test_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
40 changes: 24 additions & 16 deletions tests/auxil/bot_method_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import functools
import inspect
import re
import zoneinfo
from collections.abc import Collection, Iterable
from typing import Any, Callable, Optional

Expand All @@ -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<class_name>\w+)'\)")
""" A pattern to find a class name in a ForwardRef typing annotation.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading

0 comments on commit d0a6e51

Please sign in to comment.