8000 Bump APS & Deprecate `pytz` Support (#4582) · vavasik800/python-telegram-bot@d0a6e51 · GitHub
[go: up one dir, main page]

Skip to content
8000

Commit d0a6e51

Browse files
Bump APS & Deprecate pytz Support (python-telegram-bot#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>
1 parent 5f35304 commit d0a6e51

17 files changed

+191
-100
lines changed

.github/workflows/unit_tests.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ jobs:
6464
6565
# Test the rest
6666
export TEST_WITH_OPT_DEPS='true'
67-
pip install .[all]
67+
# need to manually install pytz here, because it's no longer in the optional reqs
68+
pip install .[all] pytz
6869
# `-n auto --dist worksteal` uses pytest-xdist to run tests on multiple CPU
6970
# workers. Increasing number of workers has little effect on test duration, but it seems
7071
# to increase flakyness.

README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ PTB can be installed with optional dependencies:
158158
* ``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``.
159159
* ``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``.
160160
* ``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>`_.
161-
* ``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``.
161+
* ``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``.
162162

163163
To install multiple optional dependencies, separate them by commas, e.g. ``pip install "python-telegram-bot[socks,webhooks]"``.
164164

pyproject.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,7 @@ http2 = [
7676
]
7777
job-queue = [
7878
# APS doesn't have a strict stability policy. Let's be cautious for now.
79-
"APScheduler~=3.10.4",
80-
# pytz is required by APS and just needs the lower bound due to #2120
81-
"pytz>=2018.6",
79+
"APScheduler>=3.10.4,<3.12.0",
8280
]
8381
passport = [
8482
"cryptography!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=39.0.1",

requirements-unit-tests.txt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@ pytest-xdist==3.6.1
1616
flaky>=3.8.1
1717

1818
# used in test_official for parsing tg docs
19-
beautifulsoup4
19+
beautifulsoup4
20+
21+
# For testing with timezones. Might not be needed on all systems, but to ensure that unit tests
22+
# run correctly on all systems, we include it here.
23+
tzdata

telegram/_utils/datetime.py

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,29 +27,34 @@
2727
user. Changes to this module are not considered breaking changes and may not be documented in
2828
the changelog.
2929
"""
30+
import contextlib
3031
import datetime as dtm
3132
import time
3233
from typing import TYPE_CHECKING, Optional, Union
3334

3435
if TYPE_CHECKING:
3536
from telegram import Bot
3637

37-
# pytz is only available if it was installed as dependency of APScheduler, so we make a little
38-
# workaround here
39-
DTM_UTC = dtm.timezone.utc
38+
UTC = dtm.timezone.utc
4039
try:
4140
import pytz
42-
43-
UTC = pytz.utc
4441
except ImportError:
45-
UTC = DTM_UTC # type: ignore[assignment]
42+
pytz = None # type: ignore[assignment]
43+
44+
45+
def localize(datetime: dtm.datetime, tzinfo: dtm.tzinfo) -> dtm.datetime:
46+
"""Localize the datetime, both for pytz and zoneinfo timezones."""
47+
if tzinfo is UTC:
48+
return datetime.replace(tzinfo=UTC)
4649

50+
with contextlib.suppress(AttributeError):
51+
# Since pytz might not be available, we need the suppress context manager
52+
if isinstance(tzinfo, pytz.BaseTzInfo):
53+
return tzinfo.localize(datetime)
4754

48-
def _localize(datetime: dtm.datetime, tzinfo: dtm.tzinfo) -> dtm.datetime:
49-
"""Localize the datetime, where UTC is handled depending on whether pytz is available or not"""
50-
if tzinfo is DTM_UTC:
51-
return datetime.replace(tzinfo=DTM_UTC)
52-
return tzinfo.localize(datetime) # type: ignore[attr-defined]
55+
if datetime.tzinfo is None:
56+
return datetime.replace(tzinfo=tzinfo)
57+
return datetime.astimezone(tzinfo)
5358

5459

5560
def to_float_timestamp(
@@ -87,7 +92,7 @@ def to_float_timestamp(
8792
will be raised.
8893
tzinfo (:class:`datetime.tzinfo`, optional): If :paramref:`time_object` is a naive object
8994
from the :mod:`datetime` module, it will be interpreted as this timezone. Defaults to
90-
``pytz.utc``, if available, and :attr:`datetime.timezone.utc` otherwise.
95+
:attr:`datetime.timezone.utc` otherwise.
9196
9297
Note:
9398
Only to be used by ``telegram.ext``.
@@ -121,6 +126,12 @@ def to_float_timestamp(
121126
return reference_timestamp + time_object
122127

123128
if tzinfo is None:
129+
# We do this here rather than in the signature to ensure that we can make calls like
130+
# to_float_timestamp(
131+
# time, tzinfo=bot.defaults.tzinfo if bot.defaults else None
132+
# )
133+
# This ensures clean separation of concerns, i.e. the default timezone should not be
134+
# the responsibility of the caller
124135
tzinfo = UTC
125136

126137
if isinstance(time_object, dtm.time):
@@ -132,15 +143,17 @@ def to_float_timestamp(
132143

133144
aware_datetime = dtm.datetime.combine(reference_date, time_object)
134145
if aware_datetime.tzinfo is None:
135-
aware_datetime = _localize(aware_datetime, tzinfo)
146+
# datetime.combine uses the tzinfo of `time_object`, which might be None
147+
# so we still need to localize
148+
aware_datetime = localize(aware_datetime, tzinfo)
136149

137150
# if the time of day has passed today, use tomorrow
138151
if reference_time > aware_datetime.timetz():
139152
aware_datetime += dtm.timedelta(days=1)
140153
return _datetime_to_float_timestamp(aware_datetime)
141154
if isinstance(time_object, dtm.datetime):
142155
if time_object.tzinfo is None:
143-
time_object = _localize(time_object, tzinfo)
156+
time_object = localize(time_object, tzinfo)
144157
return _datetime_to_float_timestamp(time_object)
145158

146159
raise TypeError(f"Unable to convert {type(time_object).__name__} object to timestamp")

telegram/ext/_defaults.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,13 @@ class Defaults:
5757
versions.
5858
tzinfo (:class:`datetime.tzinfo`, optional): A timezone to be used for all date(time)
5959
inputs appearing throughout PTB, i.e. if a timezone naive date(time) object is passed
60-
somewhere, it will be assumed to be in :paramref:`tzinfo`. If the
61-
:class:`telegram.ext.JobQueue` is used, this must be a timezone provided
62-
by the ``pytz`` module. Defaults to ``pytz.utc``, if available, and
60+
somewhere, it will be assumed to be in :paramref:`tzinfo`. Defaults to
6361
:attr:`datetime.timezone.utc` otherwise.
62+
63+
.. deprecated:: NEXT.VERSION
64+
Support for ``pytz`` timezones is deprecated and will be removed in future
65+
versions.
66+
6467
block (:obj:`bool`, optional): Default setting for the :paramref:`BaseHandler.block`
6568
parameter
6669
of handlers and error handlers registered through :meth:`Application.add_handler` and
@@ -148,6 +151,19 @@ def __init__(
148151
self._block: bool = block
149152
self._protect_content: Optional[bool] = protect_content
150153

154+
if "pytz" in str(self._tzinfo.__class__):
155+
# TODO: When dropping support, make sure to update _utils.datetime accordingly
156+
warn(
157+
message=PTBDeprecationWarning(
158+
version="NEXT.VERSION",
159+
message=(
160+
"Support for pytz timezones is deprecated and will be removed in "
161+
"future versions."
162+
),
163+
),
164+
stacklevel=2,
165+
)
166+
151167
if disable_web_page_preview is not None and link_preview_options is not None:
152168
raise ValueError(
153169
"`disable_web_page_preview` and `link_preview_options` are mutually exclusive."

telegram/ext/_jobqueue.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@
2323
from typing import TYPE_CHECKING, Any, Generic, Optional, Union, cast, overload
2424

2525
try:
26-
import pytz
2726
from apscheduler.executors.asyncio import AsyncIOExecutor
2827
from apscheduler.schedulers.asyncio import AsyncIOScheduler
2928

3029
APS_AVAILABLE = True
3130
except ImportError:
3231
APS_AVAILABLE = False
3332

33+
from telegram._utils.datetime import UTC, localize
3434
from telegram._utils.logging import get_logger
3535
from telegram._utils.repr import build_repr_with_selected_attrs
3636
from telegram._utils.types import JSONDict
@@ -155,13 +155,13 @@ def scheduler_configuration(self) -> JSONDict:
155155
dict[:obj:`str`, :obj:`object`]: The configuration values as dictionary.
156156
157157
"""
158-
timezone: object = pytz.utc
158+
timezone: dtm.tzinfo = UTC
159159
if (
160160
self._application
161161
and isinstance(self.application.bot, ExtBot)
162162
and self.application.bot.defaults
163163
):
164-
timezone = self.application.bot.defaults.tzinfo or pytz.utc
164+
timezone = self.application.bot.defaults.tzinfo or UTC
165165

166166
return {
167167
"timezone": timezone,
@@ -197,8 +197,10 @@ def _parse_time_input(
197197
dtm.datetime.now(tz=time.tzinfo or self.scheduler.timezone).date(), time
198198
)
199199
if date_time.tzinfo is None:
200-
date_time = self.scheduler.timezone.localize(date_time)
201-
if shift_day and date_time <= dtm.datetime.now(pytz.utc):
200+
# dtm.combine uses the tzinfo of `time`, which might be None, so we still have
201+
# to localize it
202+
date_time = localize(date_time, self.scheduler.timezone)
203+
if shift_day and date_time <= dtm.datetime.now(UTC):
202204
date_time += dtm.timedelta(days=1)
203205
return date_time
204206
return time

tests/_utils/test_datetime.py

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
# along with this program. If not, see [http://www.gnu.org/licenses/].
1919
import datetime as dtm
2020
import time
21+
import zoneinfo
2122

2223
import pytest
2324

@@ -55,18 +56,38 @@
5556

5657

5758
class TestDatetime:
58-
@staticmethod
59-
def localize(dt, tzinfo):
60-
if TEST_WITH_OPT_DEPS:
61-
return tzinfo.localize(dt)
62-
return dt.replace(tzinfo=tzinfo)
63-
64-
def test_helpers_utc(self):
65-
# Here we just test, that we got the correct UTC variant
66-
if not TEST_WITH_OPT_DEPS:
67-
assert tg_dtm.UTC is tg_dtm.DTM_UTC
68-
else:
69-
assert tg_dtm.UTC is not tg_dtm.DTM_UTC
59+
def test_localize_utc(self):
60+
dt = dtm.datetime(2023, 1, 1, 12, 0, 0)
61+
localized_dt = tg_dtm.localize(dt, tg_dtm.UTC)
62+
assert localized_dt.tzinfo == tg_dtm.UTC
63+
assert localized_dt == dt.replace(tzinfo=tg_dtm.UTC)
64+
65+
@pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="pytz not installed")
66+
def test_localize_pytz(self):
67+
dt = dtm.datetime(2023, 1, 1, 12, 0, 0)
68+
import pytz
69+
70+
tzinfo = pytz.timezone("Europe/Berlin")
71+
localized_dt = tg_dtm.localize(dt, tzinfo)
72+
assert localized_dt.hour == dt.hour
73+
assert localized_dt.tzinfo is not None
74+
assert tzinfo.utcoffset(dt) is not None
75+
76+
def test_localize_zoneinfo_naive(self):
77+
dt = dtm.datetime(2023, 1, 1, 12, 0, 0)
78+
tzinfo = zoneinfo.ZoneInfo("Europe/Berlin")
79+
localized_dt = tg_dtm.localize(dt, tzinfo)
80+
assert localized_dt.hour == dt.hour
81+
assert localized_dt.tzinfo is not None
82+
assert tzinfo.utcoffset(dt) is not None
83+
84+
def test_localize_zoneinfo_aware(self):
85+
dt = dtm.datetime(2023, 1, 1, 12, 0, 0, tzinfo=dtm.timezone.utc)
86+
tzinfo = zoneinfo.ZoneInfo("Europe/Berlin")
87+
localized_dt = tg_dtm.localize(dt, tzinfo)
88+
assert localized_dt.hour == dt.hour + 1
89+
assert localized_dt.tzinfo is not None
90+
assert tzinfo.utcoffset(dt) is not None
7091

7192
def test_to_float_timestamp_absolute_naive(self):
7293
"""Conversion from timezone-naive datetime to timestamp.
@@ -75,20 +96,12 @@ def test_to_float_timestamp_absolute_naive(self):
7596
datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5)
7697
assert tg_dtm.to_float_timestamp(datetime) == 1573431976.1
7798

78-
def test_to_float_timestamp_absolute_naive_no_pytz(self, monkeypatch):
79-
"""Conversion from timezone-naive datetime to timestamp.
80-
Naive datetimes should be assumed to be in UTC.
81-
"""
82-
monkeypatch.setattr(tg_dtm, "UTC", tg_dtm.DTM_UTC)
83-
datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5)
84-
assert tg_dtm.to_float_timestamp(datetime) == 1573431976.1
85-
8699
def test_to_float_timestamp_absolute_aware(self, timezone):
87100
"""Conversion from timezone-aware datetime to timestamp"""
88101
# we're parametrizing this with two different UTC offsets to exclude the possibility
89102
# of an xpass when the test is run in a timezone with the same UTC offset
90103
test_datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5)
91-
datetime = self.localize(test_datetime, timezone)
104+
datetime = tg_dtm.localize(test_datetime, timezone)
92105
assert (
93106
tg_dtm.to_float_timestamp(datetime)
94107
== 1573431976.1 - timezone.utcoffset(test_datetime).total_seconds()
@@ -126,7 +139,7 @@ def test_to_float_timestamp_time_of_day_timezone(self, timezone):
126139
ref_datetime = dtm.datetime(1970, 1, 1, 12)
127140
utc_offset = timezone.utcoffset(ref_datetime)
128141
ref_t, time_of_day = tg_dtm._datetime_to_float_timestamp(ref_datetime), ref_datetime.time()
129-
aware_time_of_day = self.localize(ref_datetime, timezone).timetz()
142+
aware_time_of_day = tg_dtm.localize(ref_datetime, timezone).timetz()
130143

131144
# first test that naive time is assumed to be utc:
132145
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):
169182
# we're parametrizing this with two different UTC offsets to exclude the possibility
170183
# of an xpass when the test is run in a timezone with the same UTC offset
171184
test_datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5)
172-
datetime = self.localize(test_datetime, timezone)
185+
datetime = tg_dtm.localize(test_datetime, timezone)
173186
assert (
174187
tg_dtm.from_timestamp(1573431976.1 - timezone.utcoffset(test_datetime).total_seconds())
175188
== datetime

tests/auxil/bot_method_checks.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import functools
2222
import inspect
2323
import re
24+
import zoneinfo
2425
from collections.abc import Collection, Iterable
2526
from typing import Any, Callable, Optional
2627

@@ -40,15 +41,11 @@
4041
Sticker,
4142
TelegramObject,
4243
)
44+
from telegram._utils.datetime import to_timestamp
4345
from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue
4446
from telegram.constants import InputMediaType
4547
from telegram.ext import Defaults, ExtBot
4648
from telegram.request import RequestData
47-
from tests.auxil.envvars import TEST_WITH_OPT_DEPS
48-
49-
if TEST_WITH_OPT_DEPS:
50-
import pytz
51-
5249

5350
FORWARD_REF_PATTERN = re.compile(r"ForwardRef\('(?P<class_name>\w+)'\)")
5451
""" A pattern to find a class name in a ForwardRef typing annotation.
@@ -344,10 +341,10 @@ def build_kwargs(
344341
# Some special casing for methods that have "exactly one of the optionals" type args
345342
elif name in ["location", "contact", "venue", "inline_message_id"]:
346343
kws[name] = True
347-
elif name == "until_date":
344+
elif name.endswith("_date"):
348345
if manually_passed_value not in [None, DEFAULT_NONE]:
349346
# Europe/Berlin
350-
kws[name] = pytz.timezone("Europe/Berlin").localize(dtm.datetime(2000, 1, 1, 0))
347+
kws[name] = dtm.datetime(2000, 1, 1, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin"))
351348
else:
352349
# naive UTC
353350
kws[name] = dtm.datetime(2000, 1, 1, 0)
@@ -395,6 +392,15 @@ def make_assertion_for_link_preview_options(
395392
)
396393

397394

395+
_EUROPE_BERLIN_TS = to_timestamp(
396+
dtm.datetime(2000, 1, 1, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin"))
397+
)
398+
_UTC_TS = to_timestamp(dtm.datetime(2000, 1, 1, 0), tzinfo=zoneinfo.ZoneInfo("UTC"))
399+
_AMERICA_NEW_YORK_TS = to_timestamp(
400+
dtm.datetime(2000, 1, 1, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York"))
401+
)
402+
403+
398404
async def make_assertion(
399405
url,
400406
request_data: RequestData,
@@ -530,14 +536,16 @@ def check_input_media(m: dict):
530536
)
531537

532538
# Check datetime conversion
533-
until_date = data.pop("until_date", None)
534-
if until_date:
535-
if manual_value_expected and until_date != 946681200:
536-
pytest.fail("Non-naive until_date should have been interpreted as Europe/Berlin.")
537-
if not any((manually_passed_value, expected_defaults_value)) and until_date != 946684800:
538-
pytest.fail("Naive until_date should have been interpreted as UTC")
539-
if default_value_expected and until_date != 946702800:
540-
pytest.fail("Naive until_date should have been interpreted as America/New_York")
539+
date_keys = [key for key in data if key.endswith("_date")]
540+
for key in date_keys:
541+
date_param = data.pop(key)
542+
if date_param:
543+
if manual_value_expected and date_param != _EUROPE_BERLIN_TS:
544+
pytest.fail(f"Non-naive `{key}` should have been interpreted as Europe/Berlin.")
545+
if not any((manually_passed_value, expected_defaults_value)) and date_param != _UTC_TS:
546+
pytest.fail(f"Naive `{key}` should have been interpreted as UTC")
547+
if default_value_expected and date_param != _AMERICA_NEW_YORK_TS:
548+
pytest.fail(f"Naive `{key}` should have been interpreted as America/New_York")
541549

542550
if method_name in ["get_file", "get_small_file", "get_big_file"]:
543551
# This is here mainly for PassportFile.get_file, which calls .set_credentials on the
@@ -596,7 +604,7 @@ async def check_defaults_handling(
596604

597605
defaults_no_custom_defaults = Defaults()
598606
kwargs = {kwarg: "custom_default" for kwarg in inspect.signature(Defaults).parameters}
599-
kwargs["tzinfo"] = pytz.timezone("America/New_York")
607+
kwargs["tzinfo"] = zoneinfo.ZoneInfo("America/New_York")
600608
kwargs.pop("disable_web_page_preview") # mutually exclusive with link_preview_options
601609
kwargs.pop("quote") # mutually exclusive with do_quote
602610
kwargs["link_preview_options"] = LinkPreviewOptions(

0 commit comments

Comments
 (0)
0