Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Bump APS & Deprecate pytz Support #4582

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading