From 40d354aa976caa9b08e9b23ee11307e595b76dec Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Tue, 8 Apr 2025 23:05:20 +0300 Subject: [PATCH 01/30] Setup helper functions and a common test fixture. --- telegram/_utils/argumentparsing.py | 31 ++++++++++++++++++++++++++++-- telegram/_utils/datetime.py | 27 ++++++++++++++++++++++++++ tests/conftest.py | 20 ++++++++++++++++++- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/telegram/_utils/argumentparsing.py b/telegram/_utils/argumentparsing.py index 84ca1bc6a2f..59ff9f4c770 100644 --- a/telegram/_utils/argumentparsing.py +++ b/telegram/_utils/argumentparsing.py @@ -23,12 +23,15 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional, Protocol, TypeVar +from typing import TYPE_CHECKING, Optional, Protocol, TypeVar, Union from telegram._linkpreviewoptions import LinkPreviewOptions from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod +from telegram._utils.warnings import warn +from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from typing import type_check_only @@ -50,6 +53,30 @@ def parse_sequence_arg(arg: Optional[Sequence[T]]) -> tuple[T, ...]: return tuple(arg) if arg else () +def parse_period_arg(arg: Optional[TimePeriod]) -> Union[dtm.timedelta, None]: + """Parses an optional time period in seconds into a timedelta + + Args: + arg (:obj:`int` | :class:`datetime.timedelta`, optional): The time period to parse. + + Returns: + :obj:`timedelta`: The time period converted to a timedelta object or :obj:`None`. + """ + if arg is None: + return None + if isinstance(arg, (int, float)): + warn( + PTBDeprecationWarning( + "NEXT.VERSION", + "In a future major version this will be of type `datetime.timedelta`." + " You can opt-in early by setting the `PTB_TIMEDELTA` environment variable.", + ), + stacklevel=2, + ) + return dtm.timedelta(seconds=arg) + return arg + + def parse_lpo_and_dwpp( disable_web_page_preview: Optional[bool], link_preview_options: ODVInput[LinkPreviewOptions] ) -> ODVInput[LinkPreviewOptions]: diff --git a/telegram/_utils/datetime.py b/telegram/_utils/datetime.py index 8e6ebdda1b4..1bf0cb1452d 100644 --- a/telegram/_utils/datetime.py +++ b/telegram/_utils/datetime.py @@ -29,9 +29,14 @@ """ import contextlib import datetime as dtm +import os import time from typing import TYPE_CHECKING, Optional, Union +from telegram._utils.warnings import warn +from telegram.warnings import PTBDeprecationWarning +from tests.auxil.envvars import env_var_2_bool + if TYPE_CHECKING: from telegram import Bot @@ -224,3 +229,25 @@ def _datetime_to_float_timestamp(dt_obj: dtm.datetime) -> float: if dt_obj.tzinfo is None: dt_obj = dt_obj.replace(tzinfo=dtm.timezone.utc) return dt_obj.timestamp() + + +def get_timedelta_value(value: Optional[dtm.timedelta]) -> Optional[Union[int, dtm.timedelta]]: + """ + Convert a `datetime.timedelta` to seconds or return it as-is, based on environment config. + """ + if value is None: + return None + + if env_var_2_bool(os.getenv("PTB_TIMEDELTA")): + return value + + warn( + PTBDeprecationWarning( + "NEXT.VERSION", + "In a future major version this will be of type `datetime.timedelta`." + " You can opt-in early by setting the `PTB_TIMEDELTA` environment variable.", + ), + stacklevel=2, + ) + # We don't want to silently drop fractions, so float is returned and we slience mypy + return value.total_seconds() # type: ignore[return-value] diff --git a/tests/conftest.py b/tests/conftest.py index 935daada498..9c1e6397c50 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio import logging +import os import sys import zoneinfo from pathlib import Path @@ -40,7 +41,12 @@ from tests.auxil.build_messages import DATE, make_message from tests.auxil.ci_bots import BOT_INFO_PROVIDER, JOB_INDEX from tests.auxil.constants import PRIVATE_KEY, TEST_TOPIC_ICON_COLOR, TEST_TOPIC_NAME -from tests.auxil.envvars import GITHUB_ACTIONS, RUN_TEST_OFFICIAL, TEST_WITH_OPT_DEPS +from tests.auxil.envvars import ( + GITHUB_ACTIONS, + RUN_TEST_OFFICIAL, + TEST_WITH_OPT_DEPS, + env_var_2_bool, +) from tests.auxil.files import data_file from tests.auxil.networking import NonchalantHttpxRequest from tests.auxil.pytest_classes import PytestBot, make_bot @@ -129,6 +135,18 @@ def _disallow_requests_in_without_request_tests(request): ) +@pytest.fixture(scope="module", params=["true", "false", None]) +def PTB_TIMEDELTA(request): + # Here we manually use monkeypatch to give this fixture module scope + monkeypatch = pytest.MonkeyPatch() + if request.param is not None: + monkeypatch.setenv("PTB_TIMEDELTA", request.param) + else: + monkeypatch.delenv("PTB_TIMEDELTA", raising=False) + yield env_var_2_bool(os.getenv("PTB_TIMEDELTA")) + monkeypatch.undo() + + # Redefine the event_loop fixture to have a session scope. Otherwise `bot` fixture can't be # session. See https://github.com/pytest-dev/pytest-asyncio/issues/68 for more details. @pytest.fixture(scope="session") From 5cd65074ab46b8b9efe4544c715eddc50b31df6d Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Tue, 8 Apr 2025 23:56:04 +0300 Subject: [PATCH 02/30] Accept timedeltas in params of `ChatFullInfo`. - ChatFullInfo.slow_mode_delay. - ChatFullInfo.message_auto_delete_time. Conflicts: telegram/_chatfullinfo.py tests/test_chatfullinfo.py tests/test_official/exceptions.py --- docs/substitutions/global.rst | 2 + telegram/_chatfullinfo.py | 81 +++++++++++++++++++++++-------- tests/test_chatfullinfo.py | 49 +++++++++++++++++-- tests/test_official/exceptions.py | 4 ++ 4 files changed, 114 insertions(+), 22 deletions(-) diff --git a/docs/substitutions/global.rst b/docs/substitutions/global.rst index 8fb9e9360d7..c53ce3ce050 100644 --- a/docs/substitutions/global.rst +++ b/docs/substitutions/global.rst @@ -101,3 +101,5 @@ .. |org-verify| replace:: `on behalf of the organization `__ .. |time-period-input| replace:: :class:`datetime.timedelta` objects are accepted in addition to plain :obj:`int` values. + +.. |timespan-seconds-deprecated| replace:: In a future major version this will be of type :obj:`datetime.timedelta`. You can opt-in early by setting the `PTB_TIMEDELTA` environment variable. diff --git a/telegram/_chatfullinfo.py b/telegram/_chatfullinfo.py index 4b0fae53c6b..879bf41e642 100644 --- a/telegram/_chatfullinfo.py +++ b/telegram/_chatfullinfo.py @@ -20,7 +20,7 @@ """This module contains an object that represents a Telegram ChatFullInfo.""" import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from telegram._birthdate import Birthdate from telegram._chat import Chat, _ChatBase @@ -29,9 +29,18 @@ from telegram._files.chatphoto import ChatPhoto from telegram._gifts import AcceptedGiftTypes from telegram._reaction import ReactionType -from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg -from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_period_arg, + parse_sequence_arg, +) +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_timedelta_value, +) +from telegram._utils.types import JSONDict, TimePeriod from telegram._utils.warnings import warn from telegram._utils.warnings_transition import ( build_deprecation_warning_message, @@ -166,17 +175,23 @@ class ChatFullInfo(_ChatBase): (by sending date). permissions (:class:`telegram.ChatPermissions`): Optional. Default chat member permissions, for groups and supergroups. - slow_mode_delay (:obj:`int`, optional): For supergroups, the minimum allowed delay between - consecutive messages sent by each unprivileged user. + slow_mode_delay (:obj:`int` | :class:`datetime.timedelta`, optional): For supergroups, + the minimum allowed delay between consecutive messages sent by each unprivileged user. + + .. versionchanged:: NEXT.VERSION + |time-period-input| unrestrict_boost_count (:obj:`int`, optional): For supergroups, the minimum number of boosts that a non-administrator user needs to add in order to ignore slow mode and chat permissions. .. versionadded:: 21.0 - message_auto_delete_time (:obj:`int`, optional): The time after which all messages sent to - the chat will be automatically deleted; in seconds. + message_auto_delete_time (:obj:`int` | :class:`datetime.timedelta`, optional): The time + after which all messages sent to the chat will be automatically deleted; in seconds. .. versionadded:: 13.4 + + .. versionchanged:: NEXT.VERSION + |time-period-input| has_aggressive_anti_spam_enabled (:obj:`bool`, optional): :obj:`True`, if aggressive anti-spam checks are enabled in the supergroup. The field is only available to chat administrators. @@ -331,17 +346,23 @@ class ChatFullInfo(_ChatBase): (by sending date). permissions (:class:`telegram.ChatPermissions`): Optional. Default chat member permissions, for groups and supergroups. - slow_mode_delay (:obj:`int`): Optional. For supergroups, the minimum allowed delay between - consecutive messages sent by each unprivileged user. + slow_mode_delay (:obj:`int` | :class:`datetime.timedelta`): Optional. For supergroups, + the minimum allowed delay between consecutive messages sent by each unprivileged user. + + .. deprecated:: NEXT.VERSION + |timespan-seconds-deprecated| unrestrict_boost_count (:obj:`int`): Optional. For supergroups, the minimum number of boosts that a non-administrator user needs to add in order to ignore slow mode and chat permissions. .. versionadded:: 21.0 - message_auto_delete_time (:obj:`int`): Optional. The time after which all messages sent to - the chat will be automatically deleted; in seconds. + message_auto_delete_time (:obj:`int` | :class:`datetime.timedelta`): Optional. The time + after which all messages sent to the chat will be automatically deleted; in seconds. .. versionadded:: 13.4 + + .. deprecated:: NEXT.VERSION + |timespan-seconds-deprecated| has_aggressive_anti_spam_enabled (:obj:`bool`): Optional. :obj:`True`, if aggressive anti-spam checks are enabled in the supergroup. The field is only available to chat administrators. @@ -383,6 +404,8 @@ class ChatFullInfo(_ChatBase): __slots__ = ( "_can_send_gift", + "_message_auto_delete_time", + "_slow_mode_delay", "accent_color_id", "accepted_gift_types", "active_usernames", @@ -411,14 +434,12 @@ class ChatFullInfo(_ChatBase): "linked_chat_id", "location", "max_reaction_count", - "message_auto_delete_time", "permissions", "personal_chat", "photo", "pinned_message", "profile_accent_color_id", "profile_background_custom_emoji_id", - "slow_mode_delay", "sticker_set_name", "unrestrict_boost_count", ) @@ -456,9 +477,9 @@ def __init__( invite_link: Optional[str] = None, pinned_message: Optional["Message"] = None, permissions: Optional[ChatPermissions] = None, - slow_mode_delay: Optional[int] = None, + slow_mode_delay: Optional[TimePeriod] = None, unrestrict_boost_count: Optional[int] = None, - message_auto_delete_time: Optional[int] = None, + message_auto_delete_time: Optional[TimePeriod] = None, has_aggressive_anti_spam_enabled: Optional[bool] = None, has_hidden_members: Optional[bool] = None, has_protected_content: Optional[bool] = None, @@ -513,9 +534,9 @@ def __init__( self.invite_link: Optional[str] = invite_link self.pinned_message: Optional[Message] = pinned_message self.permissions: Optional[ChatPermissions] = permissions - self.slow_mode_delay: Optional[int] = slow_mode_delay - self.message_auto_delete_time: Optional[int] = ( - int(message_auto_delete_time) if message_auto_delete_time is not None else None + self._slow_mode_delay: Optional[dtm.timedelta] = parse_period_arg(slow_mode_delay) + self._message_auto_delete_time: Optional[dtm.timedelta] = parse_period_arg( + message_auto_delete_time ) self.has_protected_content: Optional[bool] = has_protected_content self.has_visible_history: Optional[bool] = has_visible_history @@ -576,6 +597,14 @@ def can_send_gift(self) -> Optional[bool]: ) return self._can_send_gift + @property + def slow_mode_delay(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value(self._slow_mode_delay) + + @property + def message_auto_delete_time(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value(self._message_auto_delete_time) + @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo": """See :meth:`telegram.TelegramObject.de_json`.""" @@ -600,6 +629,13 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo": Message, ) + data["slow_mode_delay"] = ( + dtm.timedelta(seconds=s) if (s := data.get("slow_mode_delay")) else None + ) + data["message_auto_delete_time"] = ( + dtm.timedelta(seconds=s) if (s := data.get("message_auto_delete_time")) else None + ) + data["pinned_message"] = de_json_optional(data.get("pinned_message"), Message, bot) data["permissions"] = de_json_optional(data.get("permissions"), ChatPermissions, bot) data["location"] = de_json_optional(data.get("location"), ChatLocation, bot) @@ -617,3 +653,10 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo": ) return super().de_json(data=data, bot=bot) + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + out["slow_mode_delay"] = self.slow_mode_delay + out["message_auto_delete_time"] = self.message_auto_delete_time + return out diff --git a/tests/test_chatfullinfo.py b/tests/test_chatfullinfo.py index dff26aa7398..0965a3672bb 100644 --- a/tests/test_chatfullinfo.py +++ b/tests/test_chatfullinfo.py @@ -55,6 +55,7 @@ def chat_full_info(bot): can_set_sticker_set=ChatFullInfoTestBase.can_set_sticker_set, permissions=ChatFullInfoTestBase.permissions, slow_mode_delay=ChatFullInfoTestBase.slow_mode_delay, + message_auto_delete_time=ChatFullInfoTestBase.message_auto_delete_time, bio=ChatFullInfoTestBase.bio, linked_chat_id=ChatFullInfoTestBase.linked_chat_id, location=ChatFullInfoTestBase.location, @@ -107,6 +108,7 @@ class ChatFullInfoTestBase: can_invite_users=True, ) slow_mode_delay = 30 + message_auto_delete_time = 60 bio = "I'm a Barbie Girl in a Barbie World" linked_chat_id = 11880 location = ChatLocation(Location(123, 456), "Barbie World") @@ -155,7 +157,7 @@ def test_slot_behaviour(self, chat_full_info): assert len(mro_slots(cfi)) == len(set(mro_slots(cfi))), "duplicate slot" - def test_de_json(self, offline_bot): + def test_de_json(self, offline_bot, PTB_TIMEDELTA): json_dict = { "id": self.id_, "title": self.title, @@ -169,6 +171,7 @@ def test_de_json(self, offline_bot): "can_set_sticker_set": self.can_set_sticker_set, "permissions": self.permissions.to_dict(), "slow_mode_delay": self.slow_mode_delay, + "message_auto_delete_time": self.message_auto_delete_time, "bio": self.bio, "business_intro": self.business_intro.to_dict(), "business_location": self.business_location.to_dict(), @@ -201,6 +204,11 @@ def test_de_json(self, offline_bot): "last_name": self.last_name, "can_send_paid_media": self.can_send_paid_media, } + + def get_period(attr): + value = getattr(self, attr) + return dtm.timedelta(seconds=value) if PTB_TIMEDELTA else value + cfi = ChatFullInfo.de_json(json_dict, offline_bot) assert cfi.api_kwargs == {} assert cfi.id == self.id_ @@ -211,7 +219,8 @@ def test_de_json(self, offline_bot): assert cfi.sticker_set_name == self.sticker_set_name assert cfi.can_set_sticker_set == self.can_set_sticker_set assert cfi.permissions == self.permissions - assert cfi.slow_mode_delay == self.slow_mode_delay + assert cfi.slow_mode_delay == get_period("slow_mode_delay") + assert cfi.message_auto_delete_time == get_period("message_auto_delete_time") assert cfi.bio == self.bio assert cfi.business_intro == self.business_intro assert cfi.business_location == self.business_location @@ -271,7 +280,7 @@ def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): assert cfi_bot_raw.emoji_status_expiration_date.tzinfo == UTC assert emoji_expire_offset_tz == emoji_expire_offset - def test_to_dict(self, chat_full_info): + def test_to_dict(self, chat_full_info, PTB_TIMEDELTA): cfi = chat_full_info cfi_dict = cfi.to_dict() @@ -355,6 +364,40 @@ def test_can_send_gift_deprecation_warning(self): ): chat_full_info.can_send_gift + @pytest.mark.parametrize("field_name", ["slow_mode_delay", "message_auto_delete_time"]) + def test_time_period_int_deprecated(self, PTB_TIMEDELTA, recwarn, field_name): + def get_period(field_name): + value = getattr(self, field_name) + return dtm.timedelta(seconds=value) if PTB_TIMEDELTA else value + + cfi = ChatFullInfo( + id=123456, + type="dummy_type", + accent_color_id=1, + max_reaction_count=1, + accepted_gift_types=self.accepted_gift_types, + slow_mode_delay=get_period("slow_mode_delay"), + message_auto_delete_time=get_period("message_auto_delete_time"), + ) + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + assert isinstance(getattr(cfi, field_name), dtm.timedelta) + assert len(recwarn) == 0 + else: + # Two warnings from constructor + assert len(recwarn) == 2 + for i in range(2): + assert "will be of type `datetime.timedelta`" in str(recwarn[i].message) + assert recwarn[i].category is PTBDeprecationWarning + + # Trigger another warning on property access, while at it make an assertion + assert isinstance(getattr(cfi, field_name), (int, float)) + + assert len(recwarn) == 3 + assert "will be of type `datetime.timedelta`" in str(recwarn[1].message) + assert recwarn[2].category is PTBDeprecationWarning + def test_always_tuples_attributes(self): cfi = ChatFullInfo( id=123, diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index 40144f803d3..94e1cc9fd6f 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -113,6 +113,10 @@ class ParamTypeCheckingExceptions: "duration": float, # actual: dtm.timedelta "cover_frame_timestamp": float, # actual: dtm.timedelta }, + "ChatFullInfo": { + "slow_mode_delay": int, # actual: Union[int, dtm.timedelta] + "message_auto_delete_time": int, # actual: Union[int, dtm.timedelta] + }, "EncryptedPassportElement": { "data": str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress] }, From a3b340bb6405ade75d4637e9c967fb227b0a1d71 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Wed, 9 Apr 2025 00:10:28 +0000 Subject: [PATCH 03/30] Add chango fragment for PR #4750 --- changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml diff --git a/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml b/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml new file mode 100644 index 00000000000..c3667bda717 --- /dev/null +++ b/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml @@ -0,0 +1,5 @@ +other = "Use `timedelta` to represent time periods in classes" +[[pull_requests]] +uid = "4750" +author_uid = "aelkheir" +closes_threads = [] From d60b9fe5ba3af5917250988574e4780cd809fde5 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Wed, 9 Apr 2025 16:34:20 +0300 Subject: [PATCH 04/30] Refactor `test_chatfullinfo.py` a bit. Conflicts: tests/test_chatfullinfo.py --- tests/test_chatfullinfo.py | 40 +++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/test_chatfullinfo.py b/tests/test_chatfullinfo.py index 0965a3672bb..737ce57a39f 100644 --- a/tests/test_chatfullinfo.py +++ b/tests/test_chatfullinfo.py @@ -365,10 +365,8 @@ def test_can_send_gift_deprecation_warning(self): chat_full_info.can_send_gift @pytest.mark.parametrize("field_name", ["slow_mode_delay", "message_auto_delete_time"]) - def test_time_period_int_deprecated(self, PTB_TIMEDELTA, recwarn, field_name): - def get_period(field_name): - value = getattr(self, field_name) - return dtm.timedelta(seconds=value) if PTB_TIMEDELTA else value + @pytest.mark.parametrize("period", [30, dtm.timedelta(seconds=30)]) + def test_time_period_int_deprecated(self, PTB_TIMEDELTA, recwarn, field_name, period): cfi = ChatFullInfo( id=123456, @@ -376,27 +374,29 @@ def get_period(field_name): accent_color_id=1, max_reaction_count=1, accepted_gift_types=self.accepted_gift_types, - slow_mode_delay=get_period("slow_mode_delay"), - message_auto_delete_time=get_period("message_auto_delete_time"), + **{field_name: period}, ) - if PTB_TIMEDELTA: - assert len(recwarn) == 0 - assert isinstance(getattr(cfi, field_name), dtm.timedelta) - assert len(recwarn) == 0 + if isinstance(period, int): + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning else: - # Two warnings from constructor - assert len(recwarn) == 2 - for i in range(2): - assert "will be of type `datetime.timedelta`" in str(recwarn[i].message) - assert recwarn[i].category is PTBDeprecationWarning + assert len(recwarn) == 0 + + warn_count = len(recwarn) + value = getattr(cfi, field_name) - # Trigger another warning on property access, while at it make an assertion - assert isinstance(getattr(cfi, field_name), (int, float)) + if not PTB_TIMEDELTA: + # An additional warning from property access + assert len(recwarn) == warn_count + 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) + assert recwarn[-1].category is PTBDeprecationWarning - assert len(recwarn) == 3 - assert "will be of type `datetime.timedelta`" in str(recwarn[1].message) - assert recwarn[2].category is PTBDeprecationWarning + assert isinstance(value, (int, float)) + else: + assert len(recwarn) == warn_count + assert isinstance(getattr(cfi, field_name), dtm.timedelta) def test_always_tuples_attributes(self): cfi = ChatFullInfo( From d4d62ce892625e00da82eb5fb73125d24aa1f4bb Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Wed, 9 Apr 2025 16:47:39 +0300 Subject: [PATCH 05/30] Oops, so many white spaces. --- tests/test_chatfullinfo.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_chatfullinfo.py b/tests/test_chatfullinfo.py index 737ce57a39f..4ff7d1090d9 100644 --- a/tests/test_chatfullinfo.py +++ b/tests/test_chatfullinfo.py @@ -367,7 +367,6 @@ def test_can_send_gift_deprecation_warning(self): @pytest.mark.parametrize("field_name", ["slow_mode_delay", "message_auto_delete_time"]) @pytest.mark.parametrize("period", [30, dtm.timedelta(seconds=30)]) def test_time_period_int_deprecated(self, PTB_TIMEDELTA, recwarn, field_name, period): - cfi = ChatFullInfo( id=123456, type="dummy_type", @@ -392,7 +391,6 @@ def test_time_period_int_deprecated(self, PTB_TIMEDELTA, recwarn, field_name, pe assert len(recwarn) == warn_count + 1 assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) assert recwarn[-1].category is PTBDeprecationWarning - assert isinstance(value, (int, float)) else: assert len(recwarn) == warn_count From 3084d3b910062be1077df3da56f7b76267fb2fb3 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Fri, 11 Apr 2025 16:17:34 +0300 Subject: [PATCH 06/30] Finish up `ChatFullInfo` plus some helper tweaks. Conflicts: tests/test_chatfullinfo.py --- telegram/_chatfullinfo.py | 21 +++++++++++--- telegram/_utils/argumentparsing.py | 6 ++-- telegram/_utils/datetime.py | 9 ++++-- tests/test_chatfullinfo.py | 46 ++++++++++++++++++++---------- 4 files changed, 57 insertions(+), 25 deletions(-) diff --git a/telegram/_chatfullinfo.py b/telegram/_chatfullinfo.py index 879bf41e642..b14daf21b04 100644 --- a/telegram/_chatfullinfo.py +++ b/telegram/_chatfullinfo.py @@ -599,11 +599,17 @@ def can_send_gift(self) -> Optional[bool]: @property def slow_mode_delay(self) -> Optional[Union[int, dtm.timedelta]]: - return get_timedelta_value(self._slow_mode_delay) + value = get_timedelta_value(self._slow_mode_delay) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] @property def message_auto_delete_time(self) -> Optional[Union[int, dtm.timedelta]]: - return get_timedelta_value(self._message_auto_delete_time) + value = get_timedelta_value(self._message_auto_delete_time) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo": @@ -657,6 +663,13 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo": def to_dict(self, recursive: bool = True) -> JSONDict: """See :meth:`telegram.TelegramObject.to_dict`.""" out = super().to_dict(recursive) - out["slow_mode_delay"] = self.slow_mode_delay - out["message_auto_delete_time"] = self.message_auto_delete_time + + keys = ("slow_mode_delay", "message_auto_delete_time") + for key in keys: + if (value := getattr(self, "_" + key)) is not None: + seconds = value.total_seconds() + out[key] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out[key] = value + return out diff --git a/telegram/_utils/argumentparsing.py b/telegram/_utils/argumentparsing.py index 59ff9f4c770..4fdfe01a8ff 100644 --- a/telegram/_utils/argumentparsing.py +++ b/telegram/_utils/argumentparsing.py @@ -25,7 +25,7 @@ """ import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional, Protocol, TypeVar, Union +from typing import TYPE_CHECKING, Optional, Protocol, TypeVar from telegram._linkpreviewoptions import LinkPreviewOptions from telegram._telegramobject import TelegramObject @@ -53,7 +53,7 @@ def parse_sequence_arg(arg: Optional[Sequence[T]]) -> tuple[T, ...]: return tuple(arg) if arg else () -def parse_period_arg(arg: Optional[TimePeriod]) -> Union[dtm.timedelta, None]: +def parse_period_arg(arg: Optional[TimePeriod]) -> Optional[dtm.timedelta]: """Parses an optional time period in seconds into a timedelta Args: @@ -64,7 +64,7 @@ def parse_period_arg(arg: Optional[TimePeriod]) -> Union[dtm.timedelta, None]: """ if arg is None: return None - if isinstance(arg, (int, float)): + if isinstance(arg, int): warn( PTBDeprecationWarning( "NEXT.VERSION", diff --git a/telegram/_utils/datetime.py b/telegram/_utils/datetime.py index 1bf0cb1452d..aa977f8a423 100644 --- a/telegram/_utils/datetime.py +++ b/telegram/_utils/datetime.py @@ -231,9 +231,13 @@ def _datetime_to_float_timestamp(dt_obj: dtm.datetime) -> float: return dt_obj.timestamp() -def get_timedelta_value(value: Optional[dtm.timedelta]) -> Optional[Union[int, dtm.timedelta]]: +def get_timedelta_value(value: Optional[dtm.timedelta]) -> Optional[Union[float, dtm.timedelta]]: """ Convert a `datetime.timedelta` to seconds or return it as-is, based on environment config. + + This utility is part of the migration process from integer-based time representations + to using `datetime.timedelta`. The behavior is controlled by the `PTB_TIMEDELTA` + environment variable """ if value is None: return None @@ -249,5 +253,4 @@ def get_timedelta_value(value: Optional[dtm.timedelta]) -> Optional[Union[int, d ), stacklevel=2, ) - # We don't want to silently drop fractions, so float is returned and we slience mypy - return value.total_seconds() # type: ignore[return-value] + return value.total_seconds() diff --git a/tests/test_chatfullinfo.py b/tests/test_chatfullinfo.py index 4ff7d1090d9..1bee8729625 100644 --- a/tests/test_chatfullinfo.py +++ b/tests/test_chatfullinfo.py @@ -107,8 +107,8 @@ class ChatFullInfoTestBase: can_change_info=False, can_invite_users=True, ) - slow_mode_delay = 30 - message_auto_delete_time = 60 + slow_mode_delay = dtm.timedelta(seconds=30) + message_auto_delete_time = dtm.timedelta(60) bio = "I'm a Barbie Girl in a Barbie World" linked_chat_id = 11880 location = ChatLocation(Location(123, 456), "Barbie World") @@ -157,7 +157,7 @@ def test_slot_behaviour(self, chat_full_info): assert len(mro_slots(cfi)) == len(set(mro_slots(cfi))), "duplicate slot" - def test_de_json(self, offline_bot, PTB_TIMEDELTA): + def test_de_json(self, offline_bot): json_dict = { "id": self.id_, "title": self.title, @@ -170,8 +170,8 @@ def test_de_json(self, offline_bot, PTB_TIMEDELTA): "sticker_set_name": self.sticker_set_name, "can_set_sticker_set": self.can_set_sticker_set, "permissions": self.permissions.to_dict(), - "slow_mode_delay": self.slow_mode_delay, - "message_auto_delete_time": self.message_auto_delete_time, + "slow_mode_delay": self.slow_mode_delay.total_seconds(), + "message_auto_delete_time": self.message_auto_delete_time.total_seconds(), "bio": self.bio, "business_intro": self.business_intro.to_dict(), "business_location": self.business_location.to_dict(), @@ -205,10 +205,6 @@ def test_de_json(self, offline_bot, PTB_TIMEDELTA): "can_send_paid_media": self.can_send_paid_media, } - def get_period(attr): - value = getattr(self, attr) - return dtm.timedelta(seconds=value) if PTB_TIMEDELTA else value - cfi = ChatFullInfo.de_json(json_dict, offline_bot) assert cfi.api_kwargs == {} assert cfi.id == self.id_ @@ -219,8 +215,8 @@ def get_period(attr): assert cfi.sticker_set_name == self.sticker_set_name assert cfi.can_set_sticker_set == self.can_set_sticker_set assert cfi.permissions == self.permissions - assert cfi.slow_mode_delay == get_period("slow_mode_delay") - assert cfi.message_auto_delete_time == get_period("message_auto_delete_time") + assert cfi._slow_mode_delay == self.slow_mode_delay + assert cfi._message_auto_delete_time == self.message_auto_delete_time assert cfi.bio == self.bio assert cfi.business_intro == self.business_intro assert cfi.business_location == self.business_location @@ -280,7 +276,7 @@ def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): assert cfi_bot_raw.emoji_status_expiration_date.tzinfo == UTC assert emoji_expire_offset_tz == emoji_expire_offset - def test_to_dict(self, chat_full_info, PTB_TIMEDELTA): + def test_to_dict(self, chat_full_info): cfi = chat_full_info cfi_dict = cfi.to_dict() @@ -290,7 +286,10 @@ def test_to_dict(self, chat_full_info, PTB_TIMEDELTA): assert cfi_dict["type"] == cfi.type assert cfi_dict["username"] == cfi.username assert cfi_dict["permissions"] == cfi.permissions.to_dict() - assert cfi_dict["slow_mode_delay"] == cfi.slow_mode_delay + assert cfi_dict["slow_mode_delay"] == int(self.slow_mode_delay.total_seconds()) + assert cfi_dict["message_auto_delete_time"] == int( + self.message_auto_delete_time.total_seconds() + ) assert cfi_dict["bio"] == cfi.bio assert cfi_dict["business_intro"] == cfi.business_intro.to_dict() assert cfi_dict["business_location"] == cfi.business_location.to_dict() @@ -364,9 +363,26 @@ def test_can_send_gift_deprecation_warning(self): ): chat_full_info.can_send_gift + def test_time_period_properties(self, PTB_TIMEDELTA, chat_full_info): + cfi = chat_full_info + if PTB_TIMEDELTA: + assert cfi.slow_mode_delay == self.slow_mode_delay + assert isinstance(cfi.slow_mode_delay, dtm.timedelta) + + assert cfi.message_auto_delete_time == self.message_auto_delete_time + assert isinstance(cfi.message_auto_delete_time, dtm.timedelta) + else: + assert cfi.slow_mode_delay == int(self.slow_mode_delay.total_seconds()) + assert isinstance(cfi.slow_mode_delay, int) + + assert cfi.message_auto_delete_time == int( + self.message_auto_delete_time.total_seconds() + ) + assert isinstance(cfi.message_auto_delete_time, int) + @pytest.mark.parametrize("field_name", ["slow_mode_delay", "message_auto_delete_time"]) @pytest.mark.parametrize("period", [30, dtm.timedelta(seconds=30)]) - def test_time_period_int_deprecated(self, PTB_TIMEDELTA, recwarn, field_name, period): + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, field_name, period): cfi = ChatFullInfo( id=123456, type="dummy_type", @@ -394,7 +410,7 @@ def test_time_period_int_deprecated(self, PTB_TIMEDELTA, recwarn, field_name, pe assert isinstance(value, (int, float)) else: assert len(recwarn) == warn_count - assert isinstance(getattr(cfi, field_name), dtm.timedelta) + assert isinstance(value, dtm.timedelta) def test_always_tuples_attributes(self): cfi = ChatFullInfo( From 8d4875650ac81d3c2f20631476ac6c63f2184c16 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Fri, 11 Apr 2025 16:27:35 +0300 Subject: [PATCH 07/30] Modify ``docs/substitutions/global.rst``. --- docs/substitutions/global.rst | 2 +- telegram/_chatfullinfo.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/substitutions/global.rst b/docs/substitutions/global.rst index c53ce3ce050..2ff72bcda9f 100644 --- a/docs/substitutions/global.rst +++ b/docs/substitutions/global.rst @@ -102,4 +102,4 @@ .. |time-period-input| replace:: :class:`datetime.timedelta` objects are accepted in addition to plain :obj:`int` values. -.. |timespan-seconds-deprecated| replace:: In a future major version this will be of type :obj:`datetime.timedelta`. You can opt-in early by setting the `PTB_TIMEDELTA` environment variable. +.. |time-period-int-deprecated| replace:: In a future major version this will be of type :obj:`datetime.timedelta`. You can opt-in early by setting the `PTB_TIMEDELTA` environment variable. diff --git a/telegram/_chatfullinfo.py b/telegram/_chatfullinfo.py index b14daf21b04..143830d2f4d 100644 --- a/telegram/_chatfullinfo.py +++ b/telegram/_chatfullinfo.py @@ -350,7 +350,7 @@ class ChatFullInfo(_ChatBase): the minimum allowed delay between consecutive messages sent by each unprivileged user. .. deprecated:: NEXT.VERSION - |timespan-seconds-deprecated| + |time-period-int-deprecated| unrestrict_boost_count (:obj:`int`): Optional. For supergroups, the minimum number of boosts that a non-administrator user needs to add in order to ignore slow mode and chat permissions. @@ -362,7 +362,7 @@ class ChatFullInfo(_ChatBase): .. versionadded:: 13.4 .. deprecated:: NEXT.VERSION - |timespan-seconds-deprecated| + |time-period-int-deprecated| has_aggressive_anti_spam_enabled (:obj:`bool`): Optional. :obj:`True`, if aggressive anti-spam checks are enabled in the supergroup. The field is only available to chat administrators. From 8ef4ca99ce938db015bcf0b885df681aae544694 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sat, 12 Apr 2025 18:46:04 +0300 Subject: [PATCH 08/30] Accept timedeltas in ``duration`` param of media classes. This includes ``duration`` param of the following: - Animation - Audio - Video - VideoNote - Voice - PaidMediaPreview - VideoChatEnded - InputMediaVideo - InputMediaAnimation - InputMediaAudio - InputPaidMediaVideo --- telegram/_files/animation.py | 53 +++++++-- telegram/_files/audio.py | 53 +++++++-- telegram/_files/inputmedia.py | 133 ++++++++++++++++++----- telegram/_files/video.py | 44 ++++++-- telegram/_files/videonote.py | 53 +++++++-- telegram/_files/voice.py | 54 ++++++++-- telegram/_paidmedia.py | 59 ++++++++-- telegram/_videochat.py | 61 +++++++++-- tests/_files/test_animation.py | 47 +++++++- tests/_files/test_audio.py | 50 +++++++-- tests/_files/test_inputmedia.py | 174 ++++++++++++++++++++++++++++-- tests/_files/test_video.py | 50 ++++++++- tests/_files/test_videonote.py | 52 +++++++-- tests/_files/test_voice.py | 48 ++++++++- tests/test_official/exceptions.py | 4 + tests/test_paidmedia.py | 78 +++++++++++--- tests/test_videochat.py | 44 +++++++- 17 files changed, 918 insertions(+), 139 deletions(-) diff --git a/telegram/_files/animation.py b/telegram/_files/animation.py index 537ffc0a0db..0fbab6edad3 100644 --- a/telegram/_files/animation.py +++ b/telegram/_files/animation.py @@ -17,11 +17,17 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Animation.""" -from typing import Optional +import datetime as dtm +from typing import TYPE_CHECKING, Optional, Union from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod + +if TYPE_CHECKING: + from telegram import Bot class Animation(_BaseThumbedMedium): @@ -41,7 +47,11 @@ class Animation(_BaseThumbedMedium): Can't be used to download or reuse the file. width (:obj:`int`): Video width as defined by the sender. height (:obj:`int`): Video height as defined by the sender. - duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of the video + in seconds as defined by the sender. + + .. versionchanged:: NEXT.VERSION + |time-period-input| file_name (:obj:`str`, optional): Original animation filename as defined by the sender. mime_type (:obj:`str`, optional): MIME type of the file as defined by the sender. file_size (:obj:`int`, optional): File size in bytes. @@ -58,7 +68,11 @@ class Animation(_BaseThumbedMedium): Can't be used to download or reuse the file. width (:obj:`int`): Video width as defined by the sender. height (:obj:`int`): Video height as defined by the sender. - duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video in seconds + as defined by the sender. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| file_name (:obj:`str`): Optional. Original animation filename as defined by the sender. mime_type (:obj:`str`): Optional. MIME type of the file as defined by the sender. file_size (:obj:`int`): Optional. File size in bytes. @@ -69,7 +83,7 @@ class Animation(_BaseThumbedMedium): """ - __slots__ = ("duration", "file_name", "height", "mime_type", "width") + __slots__ = ("_duration", "file_name", "height", "mime_type", "width") def __init__( self, @@ -77,7 +91,7 @@ def __init__( file_unique_id: str, width: int, height: int, - duration: int, + duration: TimePeriod, file_name: Optional[str] = None, mime_type: Optional[str] = None, file_size: Optional[int] = None, @@ -96,7 +110,32 @@ def __init__( # Required self.width: int = width self.height: int = height - self.duration: int = duration + self._duration: dtm.timedelta = parse_period_arg(duration) # type: ignore[assignment] # Optional self.mime_type: Optional[str] = mime_type self.file_name: Optional[str] = file_name + + @property + def duration(self) -> Union[int, dtm.timedelta]: + value = get_timedelta_value(self._duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Animation": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None + + return super().de_json(data=data, bot=bot) + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._duration is not None: + seconds = self._duration.total_seconds() + out["duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["duration"] = self._duration + return out diff --git a/telegram/_files/audio.py b/telegram/_files/audio.py index af5e420e1b2..e8cd26c94b5 100644 --- a/telegram/_files/audio.py +++ b/telegram/_files/audio.py @@ -17,11 +17,17 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Audio.""" -from typing import Optional +import datetime as dtm +from typing import TYPE_CHECKING, Optional, Union from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod + +if TYPE_CHECKING: + from telegram import Bot class Audio(_BaseThumbedMedium): @@ -39,7 +45,11 @@ class Audio(_BaseThumbedMedium): or reuse the file. file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - duration (:obj:`int`): Duration of the audio in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the audio in + seconds as defined by the sender. + + .. versionchanged:: NEXT.VERSION + |time-period-input| performer (:obj:`str`, optional): Performer of the audio as defined by the sender or by audio tags. title (:obj:`str`, optional): Title of the audio as defined by the sender or by audio tags. @@ -56,7 +66,11 @@ class Audio(_BaseThumbedMedium): or reuse the file. file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - duration (:obj:`int`): Duration of the audio in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the audio in seconds as + defined by the sender. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| performer (:obj:`str`): Optional. Performer of the audio as defined by the sender or by audio tags. title (:obj:`str`): Optional. Title of the audio as defined by the sender or by audio tags. @@ -71,13 +85,13 @@ class Audio(_BaseThumbedMedium): """ - __slots__ = ("duration", "file_name", "mime_type", "performer", "title") + __slots__ = ("_duration", "file_name", "mime_type", "performer", "title") def __init__( self, file_id: str, file_unique_id: str, - duration: int, + duration: TimePeriod, performer: Optional[str] = None, title: Optional[str] = None, mime_type: Optional[str] = None, @@ -96,9 +110,34 @@ def __init__( ) with self._unfrozen(): # Required - self.duration: int = duration + self._duration: dtm.timedelta = parse_period_arg(duration) # type: ignore[assignment] # Optional self.performer: Optional[str] = performer self.title: Optional[str] = title self.mime_type: Optional[str] = mime_type self.file_name: Optional[str] = file_name + + @property + def duration(self) -> Union[int, dtm.timedelta]: + value = get_timedelta_value(self._duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Audio": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None + + return super().de_json(data=data, bot=bot) + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._duration is not None: + seconds = self._duration.total_seconds() + out["duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["duration"] = self._duration + return out diff --git a/telegram/_files/inputmedia.py b/telegram/_files/inputmedia.py index 2b7e6b21fd5..86add1e8745 100644 --- a/telegram/_files/inputmedia.py +++ b/telegram/_files/inputmedia.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Base class for Telegram InputMedia Objects.""" +import datetime as dtm from collections.abc import Sequence from typing import Final, Optional, Union @@ -30,10 +31,11 @@ from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject from telegram._utils import enum -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.files import parse_file_input -from telegram._utils.types import FileInput, JSONDict, ODVInput +from telegram._utils.types import FileInput, JSONDict, ODVInput, TimePeriod from telegram.constants import InputMediaType MediaType = Union[Animation, Audio, Document, PhotoSize, Video] @@ -110,6 +112,19 @@ def _parse_thumbnail_input(thumbnail: Optional[FileInput]) -> Optional[Union[str else thumbnail ) + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if isinstance(self, (InputMediaAnimation, InputMediaVideo, InputMediaAudio)): + if self._duration is not None: + seconds = self._duration.total_seconds() + # We *must* convert to int here because currently BOT API returns 'BadRequest' + # for float values + out["duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["duration"] = self._duration + return out + class InputPaidMedia(TelegramObject): """ @@ -215,7 +230,10 @@ class InputPaidMediaVideo(InputPaidMedia): .. versionchanged:: 21.11 width (:obj:`int`, optional): Video width. height (:obj:`int`, optional): Video height. - duration (:obj:`int`, optional): Video duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Video duration in seconds. + + .. versionchanged:: NEXT.VERSION + |time-period-input| supports_streaming (:obj:`bool`, optional): Pass :obj:`True`, if the uploaded video is suitable for streaming. @@ -233,14 +251,17 @@ class InputPaidMediaVideo(InputPaidMedia): .. versionchanged:: 21.11 width (:obj:`int`): Optional. Video width. height (:obj:`int`): Optional. Video height. - duration (:obj:`int`): Optional. Video duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Video duration in seconds. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| supports_streaming (:obj:`bool`): Optional. :obj:`True`, if the uploaded video is suitable for streaming. """ __slots__ = ( + "_duration", "cover", - "duration", "height", "start_timestamp", "supports_streaming", @@ -254,7 +275,7 @@ def __init__( thumbnail: Optional[FileInput] = None, width: Optional[int] = None, height: Optional[int] = None, - duration: Optional[int] = None, + duration: Optional[TimePeriod] = None, supports_streaming: Optional[bool] = None, cover: Optional[FileInput] = None, start_timestamp: Optional[int] = None, @@ -264,7 +285,7 @@ def __init__( if isinstance(media, Video): width = width if width is not None else media.width height = height if height is not None else media.height - duration = duration if duration is not None else media.duration + duration = duration if duration is not None else media._duration media = media.file_id else: # We use local_mode=True because we don't have access to the actual setting and want @@ -278,13 +299,30 @@ def __init__( ) self.width: Optional[int] = width self.height: Optional[int] = height - self.duration: Optional[int] = duration + self._duration: Optional[dtm.timedelta] = parse_period_arg(duration) self.supports_streaming: Optional[bool] = supports_streaming self.cover: Optional[Union[InputFile, str]] = ( parse_file_input(cover, attach=True, local_mode=True) if cover else None ) self.start_timestamp: Optional[int] = start_timestamp + @property + def duration(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._duration is not None: + seconds = self._duration.total_seconds() + out["duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["duration"] = self._duration + return out + class InputMediaAnimation(InputMedia): """Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. @@ -322,7 +360,11 @@ class InputMediaAnimation(InputMedia): width (:obj:`int`, optional): Animation width. height (:obj:`int`, optional): Animation height. - duration (:obj:`int`, optional): Animation duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Animation duration + in seconds. + + .. versionchanged:: NEXT.VERSION + |time-period-input| has_spoiler (:obj:`bool`, optional): Pass :obj:`True`, if the animation needs to be covered with a spoiler animation. @@ -350,7 +392,11 @@ class InputMediaAnimation(InputMedia): * |alwaystuple| width (:obj:`int`): Optional. Animation width. height (:obj:`int`): Optional. Animation height. - duration (:obj:`int`): Optional. Animation duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Animation duration + in seconds. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| has_spoiler (:obj:`bool`): Optional. :obj:`True`, if the animation is covered with a spoiler animation. @@ -364,7 +410,7 @@ class InputMediaAnimation(InputMedia): """ __slots__ = ( - "duration", + "_duration", "has_spoiler", "height", "show_caption_above_media", @@ -379,7 +425,7 @@ def __init__( parse_mode: ODVInput[str] = DEFAULT_NONE, width: Optional[int] = None, height: Optional[int] = None, - duration: Optional[int] = None, + duration: Optional[TimePeriod] = None, caption_entities: Optional[Sequence[MessageEntity]] = None, filename: Optional[str] = None, has_spoiler: Optional[bool] = None, @@ -391,7 +437,7 @@ def __init__( if isinstance(media, Animation): width = media.width if width is None else width height = media.height if height is None else height - duration = media.duration if duration is None else duration + duration = duration if duration is not None else media._duration media = media.file_id else: # We use local_mode=True because we don't have access to the actual setting and want @@ -412,10 +458,17 @@ def __init__( ) self.width: Optional[int] = width self.height: Optional[int] = height - self.duration: Optional[int] = duration + self._duration: Optional[dtm.timedelta] = parse_period_arg(duration) self.has_spoiler: Optional[bool] = has_spoiler self.show_caption_above_media: Optional[bool] = show_caption_above_media + @property + def duration(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + class InputMediaPhoto(InputMedia): """Represents a photo to be sent. @@ -545,7 +598,10 @@ class InputMediaVideo(InputMedia): width (:obj:`int`, optional): Video width. height (:obj:`int`, optional): Video height. - duration (:obj:`int`, optional): Video duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Video duration in seconds. + + .. versionchanged:: NEXT.VERSION + |time-period-input| supports_streaming (:obj:`bool`, optional): Pass :obj:`True`, if the uploaded video is suitable for streaming. has_spoiler (:obj:`bool`, optional): Pass :obj:`True`, if the video needs to be covered @@ -582,7 +638,10 @@ class InputMediaVideo(InputMedia): * |alwaystuple| width (:obj:`int`): Optional. Video width. height (:obj:`int`): Optional. Video height. - duration (:obj:`int`): Optional. Video duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Video duration in seconds. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| supports_streaming (:obj:`bool`): Optional. :obj:`True`, if the uploaded video is suitable for streaming. has_spoiler (:obj:`bool`): Optional. :obj:`True`, if the video is covered with a @@ -605,8 +664,8 @@ class InputMediaVideo(InputMedia): """ __slots__ = ( + "_duration", "cover", - "duration", "has_spoiler", "height", "show_caption_above_media", @@ -622,7 +681,7 @@ def __init__( caption: Optional[str] = None, width: Optional[int] = None, height: Optional[int] = None, - duration: Optional[int] = None, + duration: Optional[TimePeriod] = None, supports_streaming: Optional[bool] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Optional[Sequence[MessageEntity]] = None, @@ -638,7 +697,7 @@ def __init__( if isinstance(media, Video): width = width if width is not None else media.width height = height if height is not None else media.height - duration = duration if duration is not None else media.duration + duration = duration if duration is not None else media._duration media = media.file_id else: # We use local_mode=True because we don't have access to the actual setting and want @@ -656,7 +715,7 @@ def __init__( with self._unfrozen(): self.width: Optional[int] = width self.height: Optional[int] = height - self.duration: Optional[int] = duration + self._duration: Optional[dtm.timedelta] = parse_period_arg(duration) self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumbnail_input( thumbnail ) @@ -668,6 +727,13 @@ def __init__( ) self.start_timestamp: Optional[int] = start_timestamp + @property + def duration(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + class InputMediaAudio(InputMedia): """Represents an audio file to be treated as music to be sent. @@ -703,7 +769,11 @@ class InputMediaAudio(InputMedia): .. versionchanged:: 20.0 |sequenceclassargs| - duration (:obj:`int`, optional): Duration of the audio in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of the audio + in seconds as defined by the sender. + + .. versionchanged:: NEXT.VERSION + |time-period-input| performer (:obj:`str`, optional): Performer of the audio as defined by the sender or by audio tags. title (:obj:`str`, optional): Title of the audio as defined by the sender or by audio tags. @@ -725,7 +795,11 @@ class InputMediaAudio(InputMedia): * |tupleclassattrs| * |alwaystuple| - duration (:obj:`int`): Optional. Duration of the audio in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Duration of the audio + in seconds. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| performer (:obj:`str`): Optional. Performer of the audio as defined by the sender or by audio tags. title (:obj:`str`): Optional. Title of the audio as defined by the sender or by audio tags. @@ -735,14 +809,14 @@ class InputMediaAudio(InputMedia): """ - __slots__ = ("duration", "performer", "thumbnail", "title") + __slots__ = ("_duration", "performer", "thumbnail", "title") def __init__( self, media: Union[FileInput, Audio], caption: Optional[str] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - duration: Optional[int] = None, + duration: Optional[TimePeriod] = None, performer: Optional[str] = None, title: Optional[str] = None, caption_entities: Optional[Sequence[MessageEntity]] = None, @@ -752,7 +826,7 @@ def __init__( api_kwargs: Optional[JSONDict] = None, ): if isinstance(media, Audio): - duration = media.duration if duration is None else duration + duration = duration if duration is not None else media._duration performer = media.performer if performer is None else performer title = media.title if title is None else title media = media.file_id @@ -773,10 +847,17 @@ def __init__( self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumbnail_input( thumbnail ) - self.duration: Optional[int] = duration + self._duration: Optional[dtm.timedelta] = parse_period_arg(duration) self.title: Optional[str] = title self.performer: Optional[str] = performer + @property + def duration(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + class InputMediaDocument(InputMedia): """Represents a general file to be sent. diff --git a/telegram/_files/video.py b/telegram/_files/video.py index 36381ebbf6b..74c52535a09 100644 --- a/telegram/_files/video.py +++ b/telegram/_files/video.py @@ -17,13 +17,15 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Video.""" +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import de_list_optional, parse_period_arg, parse_sequence_arg +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod if TYPE_CHECKING: from telegram import Bot @@ -46,7 +48,11 @@ class Video(_BaseThumbedMedium): Can't be used to download or reuse the file. width (:obj:`int`): Video width as defined by the sender. height (:obj:`int`): Video height as defined by the sender. - duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video + in seconds as defined by the sender. + + .. versionchanged:: NEXT.VERSION + |time-period-input| file_name (:obj:`str`, optional): Original filename as defined by the sender. mime_type (:obj:`str`, optional): MIME type of a file as defined by the sender. file_size (:obj:`int`, optional): File size in bytes. @@ -69,7 +75,11 @@ class Video(_BaseThumbedMedium): Can't be used to download or reuse the file. width (:obj:`int`): Video width as defined by the sender. height (:obj:`int`): Video height as defined by the sender. - duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video in seconds + as defined by the sender. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| file_name (:obj:`str`): Optional. Original filename as defined by the sender. mime_type (:obj:`str`): Optional. MIME type of a file as defined by the sender. file_size (:obj:`int`): Optional. File size in bytes. @@ -86,8 +96,8 @@ class Video(_BaseThumbedMedium): """ __slots__ = ( + "_duration", "cover", - "duration", "file_name", "height", "mime_type", @@ -101,7 +111,7 @@ def __init__( file_unique_id: str, width: int, height: int, - duration: int, + duration: TimePeriod, mime_type: Optional[str] = None, file_size: Optional[int] = None, file_name: Optional[str] = None, @@ -122,18 +132,36 @@ def __init__( # Required self.width: int = width self.height: int = height - self.duration: int = duration + self._duration: dtm.timedelta = parse_period_arg(duration) # type: ignore[assignment] # Optional self.mime_type: Optional[str] = mime_type self.file_name: Optional[str] = file_name self.cover: Optional[Sequence[PhotoSize]] = parse_sequence_arg(cover) self.start_timestamp: Optional[int] = start_timestamp + @property + def duration(self) -> Union[int, dtm.timedelta]: + value = get_timedelta_value(self._duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Video": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) + data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None data["cover"] = de_list_optional(data.get("cover"), PhotoSize, bot) return super().de_json(data=data, bot=bot) + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._duration is not None: + seconds = self._duration.total_seconds() + out["duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["duration"] = self._duration + return out diff --git a/telegram/_files/videonote.py b/telegram/_files/videonote.py index edb9e555372..c2c21b310bc 100644 --- a/telegram/_files/videonote.py +++ b/telegram/_files/videonote.py @@ -18,11 +18,17 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram VideoNote.""" -from typing import Optional +import datetime as dtm +from typing import TYPE_CHECKING, Optional, Union from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod + +if TYPE_CHECKING: + from telegram import Bot class VideoNote(_BaseThumbedMedium): @@ -42,7 +48,11 @@ class VideoNote(_BaseThumbedMedium): Can't be used to download or reuse the file. length (:obj:`int`): Video width and height (diameter of the video message) as defined by sender. - duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video in + seconds as defined by the sender. + + .. versionchanged:: NEXT.VERSION + |time-period-input| file_size (:obj:`int`, optional): File size in bytes. thumbnail (:class:`telegram.PhotoSize`, optional): Video thumbnail. @@ -56,7 +66,11 @@ class VideoNote(_BaseThumbedMedium): Can't be used to download or reuse the file. length (:obj:`int`): Video width and height (diameter of the video message) as defined by sender. - duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video in seconds as + defined by the sender. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| file_size (:obj:`int`): Optional. File size in bytes. thumbnail (:class:`telegram.PhotoSize`): Optional. Video thumbnail. @@ -64,14 +78,14 @@ class VideoNote(_BaseThumbedMedium): """ - __slots__ = ("duration", "length") + __slots__ = ("_duration", "length") def __init__( self, file_id: str, file_unique_id: str, length: int, - duration: int, + duration: TimePeriod, file_size: Optional[int] = None, thumbnail: Optional[PhotoSize] = None, *, @@ -87,4 +101,29 @@ def __init__( with self._unfrozen(): # Required self.length: int = length - self.duration: int = duration + self._duration: dtm.timedelta = parse_period_arg(duration) # type: ignore[assignment] + + @property + def duration(self) -> Union[int, dtm.timedelta]: + value = get_timedelta_value(self._duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "VideoNote": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None + + return super().de_json(data=data, bot=bot) + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._duration is not None: + seconds = self._duration.total_seconds() + out["duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["duration"] = self._duration + return out diff --git a/telegram/_files/voice.py b/telegram/_files/voice.py index 19c0e856d14..1da486b41d8 100644 --- a/telegram/_files/voice.py +++ b/telegram/_files/voice.py @@ -17,10 +17,16 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Voice.""" -from typing import Optional +import datetime as dtm +from typing import TYPE_CHECKING, Optional, Union from telegram._files._basemedium import _BaseMedium -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod + +if TYPE_CHECKING: + from telegram import Bot class Voice(_BaseMedium): @@ -35,7 +41,11 @@ class Voice(_BaseMedium): file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - duration (:obj:`int`): Duration of the audio in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the audio in + seconds as defined by the sender. + + .. versionchanged:: NEXT.VERSION + |time-period-input| mime_type (:obj:`str`, optional): MIME type of the file as defined by the sender. file_size (:obj:`int`, optional): File size in bytes. @@ -45,19 +55,23 @@ class Voice(_BaseMedium): file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - duration (:obj:`int`): Duration of the audio in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the audio in seconds as + defined by the sender. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| mime_type (:obj:`str`): Optional. MIME type of the file as defined by the sender. file_size (:obj:`int`): Optional. File size in bytes. """ - __slots__ = ("duration", "mime_type") + __slots__ = ("_duration", "mime_type") def __init__( self, file_id: str, file_unique_id: str, - duration: int, + duration: TimePeriod, mime_type: Optional[str] = None, file_size: Optional[int] = None, *, @@ -71,6 +85,32 @@ def __init__( ) with self._unfrozen(): # Required - self.duration: int = duration + self._duration: dtm.timedelta = parse_period_arg(duration) # type: ignore[assignment] # Optional self.mime_type: Optional[str] = mime_type + + @property + def duration(self) -> Union[int, dtm.timedelta]: + value = get_timedelta_value(self._duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Voice": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None + + return super().de_json(data=data, bot=bot) + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._duration is not None: + seconds = self._duration.total_seconds() + out["duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["duration"] = self._duration + return out diff --git a/telegram/_paidmedia.py b/telegram/_paidmedia.py index 972c46fa333..52535ab9c29 100644 --- a/telegram/_paidmedia.py +++ b/telegram/_paidmedia.py @@ -18,8 +18,9 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains objects that represent paid media in Telegram.""" +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Final, Optional +from typing import TYPE_CHECKING, Final, Optional, Union from telegram import constants from telegram._files.photosize import PhotoSize @@ -27,8 +28,14 @@ from telegram._telegramobject import TelegramObject from telegram._user import User from telegram._utils import enum -from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_period_arg, + parse_sequence_arg, +) +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod if TYPE_CHECKING: from telegram import Bot @@ -98,6 +105,9 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "PaidMedia": if cls is PaidMedia and data.get("type") in _class_mapping: return _class_mapping[data.pop("type")].de_json(data=data, bot=bot) + if "duration" in data: + data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None + return super().de_json(data=data, bot=bot) @@ -110,26 +120,38 @@ class PaidMediaPreview(PaidMedia): .. versionadded:: 21.4 + .. versionchanged:: NEXT.VERSION + As part of the migration to representing time periods using ``datetime.timedelta``, + equality comparison now considers integer durations and equivalent timedeltas as equal. + Args: type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.PREVIEW`. width (:obj:`int`, optional): Media width as defined by the sender. height (:obj:`int`, optional): Media height as defined by the sender. - duration (:obj:`int`, optional): Duration of the media in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of the media in + seconds as defined by the sender. + + .. versionchanged:: NEXT.VERSION + |time-period-input| Attributes: type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.PREVIEW`. width (:obj:`int`): Optional. Media width as defined by the sender. height (:obj:`int`): Optional. Media height as defined by the sender. - duration (:obj:`int`): Optional. Duration of the media in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Duration of the media in + seconds as defined by the sender. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| """ - __slots__ = ("duration", "height", "width") + __slots__ = ("_duration", "height", "width") def __init__( self, width: Optional[int] = None, height: Optional[int] = None, - duration: Optional[int] = None, + duration: Optional[TimePeriod] = None, *, api_kwargs: Optional[JSONDict] = None, ) -> None: @@ -138,9 +160,26 @@ def __init__( with self._unfrozen(): self.width: Optional[int] = width self.height: Optional[int] = height - self.duration: Optional[int] = duration - - self._id_attrs = (self.type, self.width, self.height, self.duration) + self._duration: Optional[dtm.timedelta] = parse_period_arg(duration) + + self._id_attrs = (self.type, self.width, self.height, self._duration) + + @property + def duration(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._duration is not None: + seconds = self._duration.total_seconds() + out["duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["duration"] = self._duration + return out class PaidMediaPhoto(PaidMedia): diff --git a/telegram/_videochat.py b/telegram/_videochat.py index 7c1ec00aabb..7c2a74281a0 100644 --- a/telegram/_videochat.py +++ b/telegram/_videochat.py @@ -19,13 +19,17 @@ """This module contains objects related to Telegram video chats.""" import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from telegram._telegramobject import TelegramObject from telegram._user import User -from telegram._utils.argumentparsing import parse_sequence_arg -from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_timedelta_value, +) +from telegram._utils.types import JSONDict, TimePeriod if TYPE_CHECKING: from telegram import Bot @@ -62,28 +66,65 @@ class VideoChatEnded(TelegramObject): .. versionchanged:: 20.0 This class was renamed from ``VoiceChatEnded`` in accordance to Bot API 6.0. + .. versionchanged:: NEXT.VERSION + As part of the migration to representing time periods using ``datetime.timedelta``, + equality comparison now considers integer durations and equivalent timedeltas as equal. + Args: - duration (:obj:`int`): Voice chat duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Voice chat duration + in seconds. + + .. versionchanged:: NEXT.VERSION + |time-period-input| Attributes: - duration (:obj:`int`): Voice chat duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Voice chat duration in seconds. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| """ - __slots__ = ("duration",) + __slots__ = ("_duration",) def __init__( self, - duration: int, + duration: TimePeriod, *, api_kwargs: Optional[JSONDict] = None, ) -> None: super().__init__(api_kwargs=api_kwargs) - self.duration: int = duration - self._id_attrs = (self.duration,) + self._duration: dtm.timedelta = parse_period_arg(duration) # type: ignore[assignment] + self._id_attrs = (self._duration,) self._freeze() + @property + def duration(self) -> Union[int, dtm.timedelta]: + value = get_timedelta_value(self._duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "VideoChatEnded": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None + + return super().de_json(data=data, bot=bot) + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._duration is not None: + seconds = self._duration.total_seconds() + out["duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["duration"] = self._duration + return out + class VideoChatParticipantsInvited(TelegramObject): """ diff --git a/tests/_files/test_animation.py b/tests/_files/test_animation.py index 5ae93dd61ef..2dbe713b0d3 100644 --- a/tests/_files/test_animation.py +++ b/tests/_files/test_animation.py @@ -28,6 +28,7 @@ from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -43,7 +44,7 @@ class AnimationTestBase: animation_file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" width = 320 height = 180 - duration = 1 + duration = dtm.timedelta(seconds=1) # animation_file_url = 'https://python-telegram-bot.org/static/testfiles/game.gif' # Shortened link, the above one is cached with the wrong duration. animation_file_url = "http://bit.ly/2L18jua" @@ -77,7 +78,7 @@ def test_de_json(self, offline_bot, animation): "file_unique_id": self.animation_file_unique_id, "width": self.width, "height": self.height, - "duration": self.duration, + "duration": self.duration.total_seconds(), "thumbnail": animation.thumbnail.to_dict(), "file_name": self.file_name, "mime_type": self.mime_type, @@ -90,6 +91,7 @@ def test_de_json(self, offline_bot, animation): assert animation.file_name == self.file_name assert animation.mime_type == self.mime_type assert animation.file_size == self.file_size + assert animation._duration == self.duration def test_to_dict(self, animation): animation_dict = animation.to_dict() @@ -99,12 +101,51 @@ def test_to_dict(self, animation): assert animation_dict["file_unique_id"] == animation.file_unique_id assert animation_dict["width"] == animation.width assert animation_dict["height"] == animation.height - assert animation_dict["duration"] == animation.duration + assert animation_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(animation_dict["duration"], int) assert animation_dict["thumbnail"] == animation.thumbnail.to_dict() assert animation_dict["file_name"] == animation.file_name assert animation_dict["mime_type"] == animation.mime_type assert animation_dict["file_size"] == animation.file_size + def test_time_period_properties(self, PTB_TIMEDELTA, animation): + if PTB_TIMEDELTA: + assert animation.duration == self.duration + assert isinstance(animation.duration, dtm.timedelta) + else: + assert animation.duration == int(self.duration.total_seconds()) + assert isinstance(animation.duration, int) + + @pytest.mark.parametrize("duration", [1, dtm.timedelta(seconds=1)]) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): + animation = Animation( + self.animation_file_id, + self.animation_file_unique_id, + self.height, + self.width, + duration=duration, + ) + + if isinstance(duration, int): + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + else: + assert len(recwarn) == 0 + + warn_count = len(recwarn) + value = animation.duration + + if not PTB_TIMEDELTA: + # An additional warning from property access + assert len(recwarn) == warn_count + 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) + assert recwarn[-1].category is PTBDeprecationWarning + assert isinstance(value, (int, float)) + else: + assert len(recwarn) == warn_count + assert isinstance(value, dtm.timedelta) + def test_equality(self): a = Animation( self.animation_file_id, diff --git a/tests/_files/test_audio.py b/tests/_files/test_audio.py index 78112058cdd..8cb233d5bef 100644 --- a/tests/_files/test_audio.py +++ b/tests/_files/test_audio.py @@ -28,6 +28,7 @@ from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -43,7 +44,7 @@ class AudioTestBase: performer = "Leandro Toledo" title = "Teste" file_name = "telegram.mp3" - duration = 3 + duration = dtm.timedelta(seconds=3) # audio_file_url = 'https://python-telegram-bot.org/static/testfiles/telegram.mp3' # Shortened link, the above one is cached with the wrong duration. audio_file_url = "https://goo.gl/3En24v" @@ -71,7 +72,7 @@ def test_creation(self, audio): assert audio.file_unique_id def test_expected_values(self, audio): - assert audio.duration == self.duration + assert audio._duration == self.duration assert audio.performer is None assert audio.title is None assert audio.mime_type == self.mime_type @@ -84,7 +85,7 @@ def test_de_json(self, offline_bot, audio): json_dict = { "file_id": self.audio_file_id, "file_unique_id": self.audio_file_unique_id, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), "performer": self.performer, "title": self.title, "file_name": self.file_name, @@ -97,7 +98,7 @@ def test_de_json(self, offline_bot, audio): assert json_audio.file_id == self.audio_file_id assert json_audio.file_unique_id == self.audio_file_unique_id - assert json_audio.duration == self.duration + assert json_audio._duration == self.duration assert json_audio.performer == self.performer assert json_audio.title == self.title assert json_audio.file_name == self.file_name @@ -111,11 +112,48 @@ def test_to_dict(self, audio): assert isinstance(audio_dict, dict) assert audio_dict["file_id"] == audio.file_id assert audio_dict["file_unique_id"] == audio.file_unique_id - assert audio_dict["duration"] == audio.duration + assert audio_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(audio_dict["duration"], int) assert audio_dict["mime_type"] == audio.mime_type assert audio_dict["file_size"] == audio.file_size assert audio_dict["file_name"] == audio.file_name + def test_time_period_properties(self, PTB_TIMEDELTA, audio): + if PTB_TIMEDELTA: + assert audio.duration == self.duration + assert isinstance(audio.duration, dtm.timedelta) + else: + assert audio.duration == int(self.duration.total_seconds()) + assert isinstance(audio.duration, int) + + @pytest.mark.parametrize("duration", [3, dtm.timedelta(seconds=3)]) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): + audio = Audio( + "id", + "unique_id", + duration=duration, + ) + + if isinstance(duration, int): + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + else: + assert len(recwarn) == 0 + + warn_count = len(recwarn) + value = audio.duration + + if not PTB_TIMEDELTA: + # An additional warning from property access + assert len(recwarn) == warn_count + 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) + assert recwarn[-1].category is PTBDeprecationWarning + assert isinstance(value, (int, float)) + else: + assert len(recwarn) == warn_count + assert isinstance(value, dtm.timedelta) + def test_equality(self, audio): a = Audio(audio.file_id, audio.file_unique_id, audio.duration) b = Audio("", audio.file_unique_id, audio.duration) @@ -237,7 +275,7 @@ async def test_send_all_args(self, bot, chat_id, audio_file, thumb_file, duratio assert isinstance(message.audio.file_unique_id, str) assert message.audio.file_unique_id is not None assert message.audio.file_id is not None - assert message.audio.duration == self.duration + assert message.audio._duration == self.duration assert message.audio.performer == self.performer assert message.audio.title == self.title assert message.audio.file_name == self.file_name diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index a077c309cc5..21d9e681f86 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -18,6 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio import copy +import datetime as dtm from collections.abc import Sequence from typing import Optional @@ -40,6 +41,7 @@ from telegram.constants import InputMediaType, ParseMode from telegram.error import BadRequest from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.files import data_file from tests.auxil.networking import expect_bad_request from tests.auxil.slots import mro_slots @@ -147,7 +149,7 @@ class InputMediaVideoTestBase: caption = "My Caption" width = 3 height = 4 - duration = 5 + duration = dtm.timedelta(seconds=5) start_timestamp = 3 parse_mode = "HTML" supports_streaming = True @@ -169,7 +171,7 @@ def test_expected_values(self, input_media_video): assert input_media_video.caption == self.caption assert input_media_video.width == self.width assert input_media_video.height == self.height - assert input_media_video.duration == self.duration + assert input_media_video._duration == self.duration assert input_media_video.parse_mode == self.parse_mode assert input_media_video.caption_entities == tuple(self.caption_entities) assert input_media_video.supports_streaming == self.supports_streaming @@ -190,7 +192,8 @@ def test_to_dict(self, input_media_video): assert input_media_video_dict["caption"] == input_media_video.caption assert input_media_video_dict["width"] == input_media_video.width assert input_media_video_dict["height"] == input_media_video.height - assert input_media_video_dict["duration"] == input_media_video.duration + assert input_media_video_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(input_media_video_dict["duration"], int) assert input_media_video_dict["parse_mode"] == input_media_video.parse_mode assert input_media_video_dict["caption_entities"] == [ ce.to_dict() for ce in input_media_video.caption_entities @@ -204,7 +207,43 @@ def test_to_dict(self, input_media_video): assert input_media_video_dict["cover"] == input_media_video.cover assert input_media_video_dict["start_timestamp"] == input_media_video.start_timestamp - def test_with_video(self, video): + def test_time_period_properties(self, PTB_TIMEDELTA, input_media_video): + imv = input_media_video + if PTB_TIMEDELTA: + assert imv.duration == self.duration + assert isinstance(imv.duration, dtm.timedelta) + else: + assert imv.duration == int(self.duration.total_seconds()) + assert isinstance(imv.duration, int) + + @pytest.mark.parametrize("duration", [5, dtm.timedelta(seconds=5)]) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): + input_media_video = InputMediaVideo( + media="media", + duration=duration, + ) + + if isinstance(duration, int): + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + else: + assert len(recwarn) == 0 + + warn_count = len(recwarn) + value = input_media_video.duration + + if not PTB_TIMEDELTA: + # An additional warning from property access + assert len(recwarn) == warn_count + 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) + assert recwarn[-1].category is PTBDeprecationWarning + assert isinstance(value, (int, float)) + else: + assert len(recwarn) == warn_count + assert isinstance(value, dtm.timedelta) + + def test_with_video(self, video, PTB_TIMEDELTA): # fixture found in test_video input_media_video = InputMediaVideo(video, caption="test 3") assert input_media_video.type == self.type_ @@ -324,7 +363,7 @@ class InputMediaAnimationTestBase: caption_entities = [MessageEntity(MessageEntity.BOLD, 0, 2)] width = 30 height = 30 - duration = 1 + duration = dtm.timedelta(seconds=1) has_spoiler = True show_caption_above_media = True @@ -345,6 +384,7 @@ def test_expected_values(self, input_media_animation): assert isinstance(input_media_animation.thumbnail, InputFile) assert input_media_animation.has_spoiler == self.has_spoiler assert input_media_animation.show_caption_above_media == self.show_caption_above_media + assert input_media_animation._duration == self.duration def test_caption_entities_always_tuple(self): input_media_animation = InputMediaAnimation(self.media) @@ -361,13 +401,50 @@ def test_to_dict(self, input_media_animation): ] assert input_media_animation_dict["width"] == input_media_animation.width assert input_media_animation_dict["height"] == input_media_animation.height - assert input_media_animation_dict["duration"] == input_media_animation.duration + assert input_media_animation_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(input_media_animation_dict["duration"], int) assert input_media_animation_dict["has_spoiler"] == input_media_animation.has_spoiler assert ( input_media_animation_dict["show_caption_above_media"] == input_media_animation.show_caption_above_media ) + def test_time_period_properties(self, PTB_TIMEDELTA, input_media_animation): + ima = input_media_animation + if PTB_TIMEDELTA: + assert ima.duration == self.duration + assert isinstance(ima.duration, dtm.timedelta) + else: + assert ima.duration == int(self.duration.total_seconds()) + assert isinstance(ima.duration, int) + + @pytest.mark.parametrize("duration", [5, dtm.timedelta(seconds=5)]) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): + input_media_animation = InputMediaAnimation( + media="media", + duration=duration, + ) + + if isinstance(duration, int): + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + else: + assert len(recwarn) == 0 + + warn_count = len(recwarn) + value = input_media_animation.duration + + if not PTB_TIMEDELTA: + # An additional warning from property access + assert len(recwarn) == warn_count + 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) + assert recwarn[-1].category is PTBDeprecationWarning + assert isinstance(value, (int, float)) + else: + assert len(recwarn) == warn_count + assert isinstance(value, dtm.timedelta) + def test_with_animation(self, animation): # fixture found in test_animation input_media_animation = InputMediaAnimation(animation, caption="test 2") @@ -394,7 +471,7 @@ class InputMediaAudioTestBase: type_ = "audio" media = "NOTAREALFILEID" caption = "My Caption" - duration = 3 + duration = dtm.timedelta(seconds=3) performer = "performer" title = "title" parse_mode = "HTML" @@ -412,7 +489,7 @@ def test_expected_values(self, input_media_audio): assert input_media_audio.type == self.type_ assert input_media_audio.media == self.media assert input_media_audio.caption == self.caption - assert input_media_audio.duration == self.duration + assert input_media_audio._duration == self.duration assert input_media_audio.performer == self.performer assert input_media_audio.title == self.title assert input_media_audio.parse_mode == self.parse_mode @@ -428,7 +505,9 @@ def test_to_dict(self, input_media_audio): assert input_media_audio_dict["type"] == input_media_audio.type assert input_media_audio_dict["media"] == input_media_audio.media assert input_media_audio_dict["caption"] == input_media_audio.caption - assert input_media_audio_dict["duration"] == input_media_audio.duration + assert isinstance(input_media_audio_dict["duration"], int) + assert input_media_audio_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(input_media_audio_dict["duration"], int) assert input_media_audio_dict["performer"] == input_media_audio.performer assert input_media_audio_dict["title"] == input_media_audio.title assert input_media_audio_dict["parse_mode"] == input_media_audio.parse_mode @@ -436,6 +515,42 @@ def test_to_dict(self, input_media_audio): ce.to_dict() for ce in input_media_audio.caption_entities ] + def test_time_period_properties(self, PTB_TIMEDELTA, input_media_audio): + ima = input_media_audio + if PTB_TIMEDELTA: + assert ima.duration == self.duration + assert isinstance(ima.duration, dtm.timedelta) + else: + assert ima.duration == int(self.duration.total_seconds()) + assert isinstance(ima.duration, int) + + @pytest.mark.parametrize("duration", [5, dtm.timedelta(seconds=5)]) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): + input_media_audio = InputMediaAudio( + media="media", + duration=duration, + ) + + if isinstance(duration, int): + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + else: + assert len(recwarn) == 0 + + warn_count = len(recwarn) + value = input_media_audio.duration + + if not PTB_TIMEDELTA: + # An additional warning from property access + assert len(recwarn) == warn_count + 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) + assert recwarn[-1].category is PTBDeprecationWarning + assert isinstance(value, (int, float)) + else: + assert len(recwarn) == warn_count + assert isinstance(value, dtm.timedelta) + def test_with_audio(self, audio): # fixture found in test_audio input_media_audio = InputMediaAudio(audio, caption="test 3") @@ -574,7 +689,7 @@ def test_expected_values(self, input_paid_media_video): assert input_paid_media_video.media == self.media assert input_paid_media_video.width == self.width assert input_paid_media_video.height == self.height - assert input_paid_media_video.duration == self.duration + assert input_paid_media_video._duration == self.duration assert input_paid_media_video.supports_streaming == self.supports_streaming assert isinstance(input_paid_media_video.thumbnail, InputFile) assert isinstance(input_paid_media_video.cover, InputFile) @@ -586,7 +701,8 @@ def test_to_dict(self, input_paid_media_video): assert input_paid_media_video_dict["media"] == input_paid_media_video.media assert input_paid_media_video_dict["width"] == input_paid_media_video.width assert input_paid_media_video_dict["height"] == input_paid_media_video.height - assert input_paid_media_video_dict["duration"] == input_paid_media_video.duration + assert input_paid_media_video_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(input_paid_media_video_dict["duration"], int) assert ( input_paid_media_video_dict["supports_streaming"] == input_paid_media_video.supports_streaming @@ -598,6 +714,42 @@ def test_to_dict(self, input_paid_media_video): == input_paid_media_video.start_timestamp ) + def test_time_period_properties(self, PTB_TIMEDELTA, input_paid_media_video): + ipmv = input_paid_media_video + if PTB_TIMEDELTA: + assert ipmv.duration == self.duration + assert isinstance(ipmv.duration, dtm.timedelta) + else: + assert ipmv.duration == int(self.duration.total_seconds()) + assert isinstance(ipmv.duration, int) + + @pytest.mark.parametrize("duration", [5, dtm.timedelta(seconds=5)]) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): + input_paid_media_video = InputPaidMediaVideo( + media="media", + duration=duration, + ) + + if isinstance(duration, int): + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + else: + assert len(recwarn) == 0 + + warn_count = len(recwarn) + value = input_paid_media_video.duration + + if not PTB_TIMEDELTA: + # An additional warning from property access + assert len(recwarn) == warn_count + 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) + assert recwarn[-1].category is PTBDeprecationWarning + assert isinstance(value, (int, float)) + else: + assert len(recwarn) == warn_count + assert isinstance(value, dtm.timedelta) + def test_with_video(self, video): # fixture found in test_video input_paid_media_video = InputPaidMediaVideo(video) diff --git a/tests/_files/test_video.py b/tests/_files/test_video.py index d4d87122576..6004a24d5fe 100644 --- a/tests/_files/test_video.py +++ b/tests/_files/test_video.py @@ -28,6 +28,7 @@ from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -41,7 +42,7 @@ class VideoTestBase: width = 360 height = 640 - duration = 5 + duration = dtm.timedelta(seconds=5) file_size = 326534 mime_type = "video/mp4" supports_streaming = True @@ -80,7 +81,7 @@ def test_creation(self, video): def test_expected_values(self, video): assert video.width == self.width assert video.height == self.height - assert video.duration == self.duration + assert video._duration == self.duration assert video.file_size == self.file_size assert video.mime_type == self.mime_type @@ -90,7 +91,7 @@ def test_de_json(self, offline_bot): "file_unique_id": self.video_file_unique_id, "width": self.width, "height": self.height, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), "mime_type": self.mime_type, "file_size": self.file_size, "file_name": self.file_name, @@ -104,7 +105,7 @@ def test_de_json(self, offline_bot): assert json_video.file_unique_id == self.video_file_unique_id assert json_video.width == self.width assert json_video.height == self.height - assert json_video.duration == self.duration + assert json_video._duration == self.duration assert json_video.mime_type == self.mime_type assert json_video.file_size == self.file_size assert json_video.file_name == self.file_name @@ -119,11 +120,50 @@ def test_to_dict(self, video): assert video_dict["file_unique_id"] == video.file_unique_id assert video_dict["width"] == video.width assert video_dict["height"] == video.height - assert video_dict["duration"] == video.duration + assert video_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(video_dict["duration"], int) assert video_dict["mime_type"] == video.mime_type assert video_dict["file_size"] == video.file_size assert video_dict["file_name"] == video.file_name + def test_time_period_properties(self, PTB_TIMEDELTA, video): + if PTB_TIMEDELTA: + assert video.duration == self.duration + assert isinstance(video.duration, dtm.timedelta) + else: + assert video.duration == int(self.duration.total_seconds()) + assert isinstance(video.duration, int) + + @pytest.mark.parametrize("duration", [5, dtm.timedelta(seconds=5)]) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): + video = Video( + "video_id", + "unique_id", + 12, + 12, + duration=duration, + ) + + if isinstance(duration, int): + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + else: + assert len(recwarn) == 0 + + warn_count = len(recwarn) + value = video.duration + + if not PTB_TIMEDELTA: + # An additional warning from property access + assert len(recwarn) == warn_count + 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) + assert recwarn[-1].category is PTBDeprecationWarning + assert isinstance(value, (int, float)) + else: + assert len(recwarn) == warn_count + assert isinstance(value, dtm.timedelta) + def test_equality(self, video): a = Video(video.file_id, video.file_unique_id, self.width, self.height, self.duration) b = Video("", video.file_unique_id, self.width, self.height, self.duration) diff --git a/tests/_files/test_videonote.py b/tests/_files/test_videonote.py index 5edab597806..3f85e7addef 100644 --- a/tests/_files/test_videonote.py +++ b/tests/_files/test_videonote.py @@ -27,6 +27,7 @@ from telegram.constants import ParseMode from telegram.error import BadRequest, TelegramError from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -51,7 +52,7 @@ async def video_note(bot, chat_id): class VideoNoteTestBase: length = 240 - duration = 3 + duration = dtm.timedelta(seconds=3) file_size = 132084 thumb_width = 240 thumb_height = 240 @@ -81,17 +82,12 @@ def test_creation(self, video_note): assert video_note.thumbnail.file_id assert video_note.thumbnail.file_unique_id - def test_expected_values(self, video_note): - assert video_note.length == self.length - assert video_note.duration == self.duration - assert video_note.file_size == self.file_size - def test_de_json(self, offline_bot): json_dict = { "file_id": self.videonote_file_id, "file_unique_id": self.videonote_file_unique_id, "length": self.length, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), "file_size": self.file_size, } json_video_note = VideoNote.de_json(json_dict, offline_bot) @@ -100,7 +96,7 @@ def test_de_json(self, offline_bot): assert json_video_note.file_id == self.videonote_file_id assert json_video_note.file_unique_id == self.videonote_file_unique_id assert json_video_note.length == self.length - assert json_video_note.duration == self.duration + assert json_video_note._duration == self.duration assert json_video_note.file_size == self.file_size def test_to_dict(self, video_note): @@ -110,9 +106,47 @@ def test_to_dict(self, video_note): assert video_note_dict["file_id"] == video_note.file_id assert video_note_dict["file_unique_id"] == video_note.file_unique_id assert video_note_dict["length"] == video_note.length - assert video_note_dict["duration"] == video_note.duration + assert video_note_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(video_note_dict["duration"], int) assert video_note_dict["file_size"] == video_note.file_size + def test_time_period_properties(self, PTB_TIMEDELTA, video_note): + if PTB_TIMEDELTA: + assert video_note.duration == self.duration + assert isinstance(video_note.duration, dtm.timedelta) + else: + assert video_note.duration == int(self.duration.total_seconds()) + assert isinstance(video_note.duration, int) + + @pytest.mark.parametrize("duration", [3, dtm.timedelta(seconds=3)]) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): + video_note = VideoNote( + "video_note_id", + "unique_id", + 20, + duration=duration, + ) + + if isinstance(duration, int): + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + else: + assert len(recwarn) == 0 + + warn_count = len(recwarn) + value = video_note.duration + + if not PTB_TIMEDELTA: + # An additional warning from property access + assert len(recwarn) == warn_count + 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) + assert recwarn[-1].category is PTBDeprecationWarning + assert isinstance(value, (int, float)) + else: + assert len(recwarn) == warn_count + assert isinstance(value, dtm.timedelta) + def test_equality(self, video_note): a = VideoNote(video_note.file_id, video_note.file_unique_id, self.length, self.duration) b = VideoNote("", video_note.file_unique_id, self.length, self.duration) diff --git a/tests/_files/test_voice.py b/tests/_files/test_voice.py index c06b1218139..a2fdd4e1317 100644 --- a/tests/_files/test_voice.py +++ b/tests/_files/test_voice.py @@ -28,6 +28,7 @@ from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -51,7 +52,7 @@ async def voice(bot, chat_id): class VoiceTestBase: - duration = 3 + duration = dtm.timedelta(seconds=3) mime_type = "audio/ogg" file_size = 9199 caption = "Test *voice*" @@ -75,7 +76,7 @@ async def test_creation(self, voice): assert voice.file_unique_id def test_expected_values(self, voice): - assert voice.duration == self.duration + assert voice._duration == self.duration assert voice.mime_type == self.mime_type assert voice.file_size == self.file_size @@ -83,7 +84,7 @@ def test_de_json(self, offline_bot): json_dict = { "file_id": self.voice_file_id, "file_unique_id": self.voice_file_unique_id, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), "mime_type": self.mime_type, "file_size": self.file_size, } @@ -92,7 +93,7 @@ def test_de_json(self, offline_bot): assert json_voice.file_id == self.voice_file_id assert json_voice.file_unique_id == self.voice_file_unique_id - assert json_voice.duration == self.duration + assert json_voice._duration == self.duration assert json_voice.mime_type == self.mime_type assert json_voice.file_size == self.file_size @@ -102,10 +103,47 @@ def test_to_dict(self, voice): assert isinstance(voice_dict, dict) assert voice_dict["file_id"] == voice.file_id assert voice_dict["file_unique_id"] == voice.file_unique_id - assert voice_dict["duration"] == voice.duration + assert voice_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(voice_dict["duration"], int) assert voice_dict["mime_type"] == voice.mime_type assert voice_dict["file_size"] == voice.file_size + def test_time_period_properties(self, PTB_TIMEDELTA, voice): + if PTB_TIMEDELTA: + assert voice.duration == self.duration + assert isinstance(voice.duration, dtm.timedelta) + else: + assert voice.duration == int(self.duration.total_seconds()) + assert isinstance(voice.duration, int) + + @pytest.mark.parametrize("duration", [3, dtm.timedelta(seconds=3)]) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): + voice = Voice( + "voice_id", + "unique_id", + duration=duration, + ) + + if isinstance(duration, int): + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + else: + assert len(recwarn) == 0 + + warn_count = len(recwarn) + value = voice.duration + + if not PTB_TIMEDELTA: + # An additional warning from property access + assert len(recwarn) == warn_count + 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) + assert recwarn[-1].category is PTBDeprecationWarning + assert isinstance(value, (int, float)) + else: + assert len(recwarn) == warn_count + assert isinstance(value, dtm.timedelta) + def test_equality(self, voice): a = Voice(voice.file_id, voice.file_unique_id, self.duration) b = Voice("", voice.file_unique_id, self.duration) diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index 94e1cc9fd6f..ec4ef5850e9 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -117,6 +117,10 @@ class ParamTypeCheckingExceptions: "slow_mode_delay": int, # actual: Union[int, dtm.timedelta] "message_auto_delete_time": int, # actual: Union[int, dtm.timedelta] }, + "Animation|Audio|Voice|Video(Note|ChatEnded)?|PaidMediaPreview" + "|Input(Paid)?Media(Audio|Video|Animation)": { + "duration": int, # actual: Union[int, dtm.timedelta] + }, "EncryptedPassportElement": { "data": str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress] }, diff --git a/tests/test_paidmedia.py b/tests/test_paidmedia.py index a696c416b58..1e050dfce24 100644 --- a/tests/test_paidmedia.py +++ b/tests/test_paidmedia.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm from copy import deepcopy import pytest @@ -34,6 +35,7 @@ Video, ) from telegram.constants import PaidMediaType +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -46,13 +48,13 @@ class PaidMediaTestBase: type = PaidMediaType.PHOTO width = 640 height = 480 - duration = 60 + duration = dtm.timedelta(60) video = Video( file_id="video_file_id", width=640, height=480, file_unique_id="file_unique_id", - duration=60, + duration=dtm.timedelta(seconds=60), ) photo = ( PhotoSize( @@ -96,14 +98,17 @@ def test_de_json_subclass(self, offline_bot, pm_type, subclass): "photo": [p.to_dict() for p in self.photo], "width": self.width, "height": self.height, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), } pm = PaidMedia.de_json(json_dict, offline_bot) + # TODO: Should be removed when the timedelta migartion is complete + extra_slots = {"duration"} if subclass is PaidMediaPreview else set() + assert type(pm) is subclass - assert set(pm.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { - "type" - } + assert set(pm.api_kwargs.keys()) == set(json_dict.keys()) - ( + set(subclass.__slots__) | extra_slots + ) - {"type"} assert pm.type == pm_type def test_to_dict(self, paid_media): @@ -243,21 +248,23 @@ def test_de_json(self, offline_bot): json_dict = { "width": self.width, "height": self.height, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), } pmp = PaidMediaPreview.de_json(json_dict, offline_bot) assert pmp.width == self.width assert pmp.height == self.height - assert pmp.duration == self.duration + assert pmp._duration == self.duration assert pmp.api_kwargs == {} def test_to_dict(self, paid_media_preview): - assert paid_media_preview.to_dict() == { - "type": paid_media_preview.type, - "width": self.width, - "height": self.height, - "duration": self.duration, - } + paid_media_preview_dict = paid_media_preview.to_dict() + + assert isinstance(paid_media_preview_dict, dict) + assert paid_media_preview_dict["type"] == paid_media_preview.type + assert paid_media_preview_dict["width"] == paid_media_preview.width + assert paid_media_preview_dict["height"] == paid_media_preview.height + assert paid_media_preview_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(paid_media_preview_dict["duration"], int) def test_equality(self, paid_media_preview): a = paid_media_preview @@ -266,6 +273,11 @@ def test_equality(self, paid_media_preview): height=self.height, duration=self.duration, ) + x = PaidMediaPreview( + width=self.width, + height=self.height, + duration=int(self.duration.total_seconds()), + ) c = PaidMediaPreview( width=100, height=100, @@ -274,7 +286,9 @@ def test_equality(self, paid_media_preview): d = Dice(5, "test") assert a == b + assert b == x assert hash(a) == hash(b) + assert hash(b) == hash(x) assert a != c assert hash(a) != hash(c) @@ -282,6 +296,42 @@ def test_equality(self, paid_media_preview): assert a != d assert hash(a) != hash(d) + def test_time_period_properties(self, PTB_TIMEDELTA, paid_media_preview): + pmp = paid_media_preview + if PTB_TIMEDELTA: + assert pmp.duration == self.duration + assert isinstance(pmp.duration, dtm.timedelta) + else: + assert pmp.duration == int(self.duration.total_seconds()) + assert isinstance(pmp.duration, int) + + @pytest.mark.parametrize("duration", [60, dtm.timedelta(seconds=60)]) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): + pmp = PaidMediaPreview( + width=self.width, + height=self.height, + duration=duration, + ) + + if isinstance(duration, int): + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + else: + assert len(recwarn) == 0 + + warn_count = len(recwarn) + value = pmp.duration + + if not PTB_TIMEDELTA: + assert len(recwarn) == warn_count + 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) + assert recwarn[-1].category is PTBDeprecationWarning + assert isinstance(value, (int, float)) + else: + assert len(recwarn) == warn_count + assert isinstance(value, dtm.timedelta) + # =========================================================================================== # =========================================================================================== diff --git a/tests/test_videochat.py b/tests/test_videochat.py index 57d91003c29..3752d6790b2 100644 --- a/tests/test_videochat.py +++ b/tests/test_videochat.py @@ -28,6 +28,7 @@ VideoChatStarted, ) from telegram._utils.datetime import UTC, to_timestamp +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -60,7 +61,7 @@ def test_to_dict(self): class TestVideoChatEndedWithoutRequest: - duration = 100 + duration = dtm.timedelta(seconds=100) def test_slot_behaviour(self): action = VideoChatEnded(8) @@ -69,27 +70,62 @@ def test_slot_behaviour(self): assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" def test_de_json(self): - json_dict = {"duration": self.duration} + json_dict = {"duration": int(self.duration.total_seconds())} video_chat_ended = VideoChatEnded.de_json(json_dict, None) assert video_chat_ended.api_kwargs == {} - assert video_chat_ended.duration == self.duration + assert video_chat_ended._duration == self.duration def test_to_dict(self): video_chat_ended = VideoChatEnded(self.duration) video_chat_dict = video_chat_ended.to_dict() assert isinstance(video_chat_dict, dict) - assert video_chat_dict["duration"] == self.duration + assert video_chat_dict["duration"] == int(self.duration.total_seconds()) + + def test_time_period_properties(self, PTB_TIMEDELTA): + vce = VideoChatEnded(duration=self.duration) + if PTB_TIMEDELTA: + assert vce.duration == self.duration + assert isinstance(vce.duration, dtm.timedelta) + else: + assert vce.duration == int(self.duration.total_seconds()) + assert isinstance(vce.duration, int) + + @pytest.mark.parametrize("duration", [100, dtm.timedelta(seconds=100)]) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): + video_chat_ended = VideoChatEnded(duration) + + if isinstance(duration, int): + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + else: + assert len(recwarn) == 0 + + warn_count = len(recwarn) + value = video_chat_ended.duration + + if not PTB_TIMEDELTA: + assert len(recwarn) == warn_count + 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) + assert recwarn[-1].category is PTBDeprecationWarning + assert isinstance(value, (int, float)) + else: + assert len(recwarn) == warn_count + assert isinstance(value, dtm.timedelta) def test_equality(self): a = VideoChatEnded(100) b = VideoChatEnded(100) + x = VideoChatEnded(dtm.timedelta(seconds=100)) c = VideoChatEnded(50) d = VideoChatStarted() assert a == b assert hash(a) == hash(b) + assert b == x + assert hash(b) == hash(x) assert a != c assert hash(a) != hash(c) From 28af9f75df1a40ebd347ad320b3988d2b88f6473 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Fri, 16 May 2025 01:02:56 +0300 Subject: [PATCH 09/30] Undeprecate passing `ints` to classes' arguments. Conflicts: tests/test_paidmedia.py tests/test_videochat.py --- telegram/_utils/argumentparsing.py | 10 -- tests/_files/test_animation.py | 30 +----- tests/_files/test_audio.py | 28 +----- tests/_files/test_inputmedia.py | 152 +++++++++-------------------- tests/_files/test_video.py | 30 +----- tests/_files/test_videonote.py | 29 +----- tests/_files/test_voice.py | 28 +----- tests/test_chatfullinfo.py | 36 ++----- tests/test_paidmedia.py | 38 +++----- tests/test_videochat.py | 34 +++---- 10 files changed, 99 insertions(+), 316 deletions(-) diff --git a/telegram/_utils/argumentparsing.py b/telegram/_utils/argumentparsing.py index 4fdfe01a8ff..8d981c1439d 100644 --- a/telegram/_utils/argumentparsing.py +++ b/telegram/_utils/argumentparsing.py @@ -30,8 +30,6 @@ from telegram._linkpreviewoptions import LinkPreviewOptions from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict, ODVInput, TimePeriod -from telegram._utils.warnings import warn -from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from typing import type_check_only @@ -65,14 +63,6 @@ def parse_period_arg(arg: Optional[TimePeriod]) -> Optional[dtm.timedelta]: if arg is None: return None if isinstance(arg, int): - warn( - PTBDeprecationWarning( - "NEXT.VERSION", - "In a future major version this will be of type `datetime.timedelta`." - " You can opt-in early by setting the `PTB_TIMEDELTA` environment variable.", - ), - stacklevel=2, - ) return dtm.timedelta(seconds=arg) return arg diff --git a/tests/_files/test_animation.py b/tests/_files/test_animation.py index 2dbe713b0d3..654687f224b 100644 --- a/tests/_files/test_animation.py +++ b/tests/_files/test_animation.py @@ -116,35 +116,15 @@ def test_time_period_properties(self, PTB_TIMEDELTA, animation): assert animation.duration == int(self.duration.total_seconds()) assert isinstance(animation.duration, int) - @pytest.mark.parametrize("duration", [1, dtm.timedelta(seconds=1)]) - def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): - animation = Animation( - self.animation_file_id, - self.animation_file_unique_id, - self.height, - self.width, - duration=duration, - ) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, animation): + animation.duration - if isinstance(duration, int): + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: assert len(recwarn) == 1 assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning - else: - assert len(recwarn) == 0 - - warn_count = len(recwarn) - value = animation.duration - - if not PTB_TIMEDELTA: - # An additional warning from property access - assert len(recwarn) == warn_count + 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) - assert recwarn[-1].category is PTBDeprecationWarning - assert isinstance(value, (int, float)) - else: - assert len(recwarn) == warn_count - assert isinstance(value, dtm.timedelta) def test_equality(self): a = Animation( diff --git a/tests/_files/test_audio.py b/tests/_files/test_audio.py index 8cb233d5bef..5e8d14fa907 100644 --- a/tests/_files/test_audio.py +++ b/tests/_files/test_audio.py @@ -126,33 +126,15 @@ def test_time_period_properties(self, PTB_TIMEDELTA, audio): assert audio.duration == int(self.duration.total_seconds()) assert isinstance(audio.duration, int) - @pytest.mark.parametrize("duration", [3, dtm.timedelta(seconds=3)]) - def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): - audio = Audio( - "id", - "unique_id", - duration=duration, - ) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, audio): + audio.duration - if isinstance(duration, int): + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: assert len(recwarn) == 1 assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning - else: - assert len(recwarn) == 0 - - warn_count = len(recwarn) - value = audio.duration - - if not PTB_TIMEDELTA: - # An additional warning from property access - assert len(recwarn) == warn_count + 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) - assert recwarn[-1].category is PTBDeprecationWarning - assert isinstance(value, (int, float)) - else: - assert len(recwarn) == warn_count - assert isinstance(value, dtm.timedelta) def test_equality(self, audio): a = Audio(audio.file_id, audio.file_unique_id, audio.duration) diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index 21d9e681f86..3b7aa9535e6 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -208,40 +208,24 @@ def test_to_dict(self, input_media_video): assert input_media_video_dict["start_timestamp"] == input_media_video.start_timestamp def test_time_period_properties(self, PTB_TIMEDELTA, input_media_video): - imv = input_media_video + duration = input_media_video.duration + if PTB_TIMEDELTA: - assert imv.duration == self.duration - assert isinstance(imv.duration, dtm.timedelta) + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) else: - assert imv.duration == int(self.duration.total_seconds()) - assert isinstance(imv.duration, int) + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) - @pytest.mark.parametrize("duration", [5, dtm.timedelta(seconds=5)]) - def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): - input_media_video = InputMediaVideo( - media="media", - duration=duration, - ) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_video): + input_media_video.duration - if isinstance(duration, int): + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: assert len(recwarn) == 1 assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning - else: - assert len(recwarn) == 0 - - warn_count = len(recwarn) - value = input_media_video.duration - - if not PTB_TIMEDELTA: - # An additional warning from property access - assert len(recwarn) == warn_count + 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) - assert recwarn[-1].category is PTBDeprecationWarning - assert isinstance(value, (int, float)) - else: - assert len(recwarn) == warn_count - assert isinstance(value, dtm.timedelta) def test_with_video(self, video, PTB_TIMEDELTA): # fixture found in test_video @@ -410,40 +394,24 @@ def test_to_dict(self, input_media_animation): ) def test_time_period_properties(self, PTB_TIMEDELTA, input_media_animation): - ima = input_media_animation + duration = input_media_animation.duration + if PTB_TIMEDELTA: - assert ima.duration == self.duration - assert isinstance(ima.duration, dtm.timedelta) + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) else: - assert ima.duration == int(self.duration.total_seconds()) - assert isinstance(ima.duration, int) + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) - @pytest.mark.parametrize("duration", [5, dtm.timedelta(seconds=5)]) - def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): - input_media_animation = InputMediaAnimation( - media="media", - duration=duration, - ) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_animation): + input_media_animation.duration - if isinstance(duration, int): + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: assert len(recwarn) == 1 assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning - else: - assert len(recwarn) == 0 - - warn_count = len(recwarn) - value = input_media_animation.duration - - if not PTB_TIMEDELTA: - # An additional warning from property access - assert len(recwarn) == warn_count + 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) - assert recwarn[-1].category is PTBDeprecationWarning - assert isinstance(value, (int, float)) - else: - assert len(recwarn) == warn_count - assert isinstance(value, dtm.timedelta) def test_with_animation(self, animation): # fixture found in test_animation @@ -516,40 +484,24 @@ def test_to_dict(self, input_media_audio): ] def test_time_period_properties(self, PTB_TIMEDELTA, input_media_audio): - ima = input_media_audio + duration = input_media_audio.duration + if PTB_TIMEDELTA: - assert ima.duration == self.duration - assert isinstance(ima.duration, dtm.timedelta) + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) else: - assert ima.duration == int(self.duration.total_seconds()) - assert isinstance(ima.duration, int) + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) - @pytest.mark.parametrize("duration", [5, dtm.timedelta(seconds=5)]) - def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): - input_media_audio = InputMediaAudio( - media="media", - duration=duration, - ) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_audio): + input_media_audio.duration - if isinstance(duration, int): + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: assert len(recwarn) == 1 assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning - else: - assert len(recwarn) == 0 - - warn_count = len(recwarn) - value = input_media_audio.duration - - if not PTB_TIMEDELTA: - # An additional warning from property access - assert len(recwarn) == warn_count + 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) - assert recwarn[-1].category is PTBDeprecationWarning - assert isinstance(value, (int, float)) - else: - assert len(recwarn) == warn_count - assert isinstance(value, dtm.timedelta) def test_with_audio(self, audio): # fixture found in test_audio @@ -715,40 +667,24 @@ def test_to_dict(self, input_paid_media_video): ) def test_time_period_properties(self, PTB_TIMEDELTA, input_paid_media_video): - ipmv = input_paid_media_video + duration = input_paid_media_video.duration + if PTB_TIMEDELTA: - assert ipmv.duration == self.duration - assert isinstance(ipmv.duration, dtm.timedelta) + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) else: - assert ipmv.duration == int(self.duration.total_seconds()) - assert isinstance(ipmv.duration, int) + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) - @pytest.mark.parametrize("duration", [5, dtm.timedelta(seconds=5)]) - def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): - input_paid_media_video = InputPaidMediaVideo( - media="media", - duration=duration, - ) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_paid_media_video): + input_paid_media_video.duration - if isinstance(duration, int): + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: assert len(recwarn) == 1 assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning - else: - assert len(recwarn) == 0 - - warn_count = len(recwarn) - value = input_paid_media_video.duration - - if not PTB_TIMEDELTA: - # An additional warning from property access - assert len(recwarn) == warn_count + 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) - assert recwarn[-1].category is PTBDeprecationWarning - assert isinstance(value, (int, float)) - else: - assert len(recwarn) == warn_count - assert isinstance(value, dtm.timedelta) def test_with_video(self, video): # fixture found in test_video diff --git a/tests/_files/test_video.py b/tests/_files/test_video.py index 6004a24d5fe..b976386ea37 100644 --- a/tests/_files/test_video.py +++ b/tests/_files/test_video.py @@ -134,35 +134,15 @@ def test_time_period_properties(self, PTB_TIMEDELTA, video): assert video.duration == int(self.duration.total_seconds()) assert isinstance(video.duration, int) - @pytest.mark.parametrize("duration", [5, dtm.timedelta(seconds=5)]) - def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): - video = Video( - "video_id", - "unique_id", - 12, - 12, - duration=duration, - ) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, video): + video.duration - if isinstance(duration, int): + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: assert len(recwarn) == 1 assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning - else: - assert len(recwarn) == 0 - - warn_count = len(recwarn) - value = video.duration - - if not PTB_TIMEDELTA: - # An additional warning from property access - assert len(recwarn) == warn_count + 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) - assert recwarn[-1].category is PTBDeprecationWarning - assert isinstance(value, (int, float)) - else: - assert len(recwarn) == warn_count - assert isinstance(value, dtm.timedelta) def test_equality(self, video): a = Video(video.file_id, video.file_unique_id, self.width, self.height, self.duration) diff --git a/tests/_files/test_videonote.py b/tests/_files/test_videonote.py index 3f85e7addef..26e56227119 100644 --- a/tests/_files/test_videonote.py +++ b/tests/_files/test_videonote.py @@ -118,34 +118,15 @@ def test_time_period_properties(self, PTB_TIMEDELTA, video_note): assert video_note.duration == int(self.duration.total_seconds()) assert isinstance(video_note.duration, int) - @pytest.mark.parametrize("duration", [3, dtm.timedelta(seconds=3)]) - def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): - video_note = VideoNote( - "video_note_id", - "unique_id", - 20, - duration=duration, - ) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, video_note): + video_note.duration - if isinstance(duration, int): + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: assert len(recwarn) == 1 assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning - else: - assert len(recwarn) == 0 - - warn_count = len(recwarn) - value = video_note.duration - - if not PTB_TIMEDELTA: - # An additional warning from property access - assert len(recwarn) == warn_count + 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) - assert recwarn[-1].category is PTBDeprecationWarning - assert isinstance(value, (int, float)) - else: - assert len(recwarn) == warn_count - assert isinstance(value, dtm.timedelta) def test_equality(self, video_note): a = VideoNote(video_note.file_id, video_note.file_unique_id, self.length, self.duration) diff --git a/tests/_files/test_voice.py b/tests/_files/test_voice.py index a2fdd4e1317..eb8ec0358f1 100644 --- a/tests/_files/test_voice.py +++ b/tests/_files/test_voice.py @@ -116,33 +116,15 @@ def test_time_period_properties(self, PTB_TIMEDELTA, voice): assert voice.duration == int(self.duration.total_seconds()) assert isinstance(voice.duration, int) - @pytest.mark.parametrize("duration", [3, dtm.timedelta(seconds=3)]) - def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): - voice = Voice( - "voice_id", - "unique_id", - duration=duration, - ) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, voice): + voice.duration - if isinstance(duration, int): + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: assert len(recwarn) == 1 assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning - else: - assert len(recwarn) == 0 - - warn_count = len(recwarn) - value = voice.duration - - if not PTB_TIMEDELTA: - # An additional warning from property access - assert len(recwarn) == warn_count + 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) - assert recwarn[-1].category is PTBDeprecationWarning - assert isinstance(value, (int, float)) - else: - assert len(recwarn) == warn_count - assert isinstance(value, dtm.timedelta) def test_equality(self, voice): a = Voice(voice.file_id, voice.file_unique_id, self.duration) diff --git a/tests/test_chatfullinfo.py b/tests/test_chatfullinfo.py index 1bee8729625..cb1848cfd81 100644 --- a/tests/test_chatfullinfo.py +++ b/tests/test_chatfullinfo.py @@ -380,37 +380,17 @@ def test_time_period_properties(self, PTB_TIMEDELTA, chat_full_info): ) assert isinstance(cfi.message_auto_delete_time, int) - @pytest.mark.parametrize("field_name", ["slow_mode_delay", "message_auto_delete_time"]) - @pytest.mark.parametrize("period", [30, dtm.timedelta(seconds=30)]) - def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, field_name, period): - cfi = ChatFullInfo( - id=123456, - type="dummy_type", - accent_color_id=1, - max_reaction_count=1, - accepted_gift_types=self.accepted_gift_types, - **{field_name: period}, - ) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, chat_full_info): + chat_full_info.slow_mode_delay + chat_full_info.message_auto_delete_time - if isinstance(period, int): - assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) - assert recwarn[0].category is PTBDeprecationWarning - else: + if PTB_TIMEDELTA: assert len(recwarn) == 0 - - warn_count = len(recwarn) - value = getattr(cfi, field_name) - - if not PTB_TIMEDELTA: - # An additional warning from property access - assert len(recwarn) == warn_count + 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) - assert recwarn[-1].category is PTBDeprecationWarning - assert isinstance(value, (int, float)) else: - assert len(recwarn) == warn_count - assert isinstance(value, dtm.timedelta) + assert len(recwarn) == 2 + for i in range(2): + assert "will be of type `datetime.timedelta`" in str(recwarn[i].message) + assert recwarn[i].category is PTBDeprecationWarning def test_always_tuples_attributes(self): cfi = ChatFullInfo( diff --git a/tests/test_paidmedia.py b/tests/test_paidmedia.py index 1e050dfce24..e2a9af11abd 100644 --- a/tests/test_paidmedia.py +++ b/tests/test_paidmedia.py @@ -297,40 +297,24 @@ def test_equality(self, paid_media_preview): assert hash(a) != hash(d) def test_time_period_properties(self, PTB_TIMEDELTA, paid_media_preview): - pmp = paid_media_preview + duration = paid_media_preview.duration + if PTB_TIMEDELTA: - assert pmp.duration == self.duration - assert isinstance(pmp.duration, dtm.timedelta) + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) else: - assert pmp.duration == int(self.duration.total_seconds()) - assert isinstance(pmp.duration, int) + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) - @pytest.mark.parametrize("duration", [60, dtm.timedelta(seconds=60)]) - def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): - pmp = PaidMediaPreview( - width=self.width, - height=self.height, - duration=duration, - ) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, paid_media_preview): + paid_media_preview.duration - if isinstance(duration, int): + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: assert len(recwarn) == 1 assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning - else: - assert len(recwarn) == 0 - - warn_count = len(recwarn) - value = pmp.duration - - if not PTB_TIMEDELTA: - assert len(recwarn) == warn_count + 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) - assert recwarn[-1].category is PTBDeprecationWarning - assert isinstance(value, (int, float)) - else: - assert len(recwarn) == warn_count - assert isinstance(value, dtm.timedelta) # =========================================================================================== diff --git a/tests/test_videochat.py b/tests/test_videochat.py index 3752d6790b2..61e042d8e60 100644 --- a/tests/test_videochat.py +++ b/tests/test_videochat.py @@ -84,36 +84,24 @@ def test_to_dict(self): assert video_chat_dict["duration"] == int(self.duration.total_seconds()) def test_time_period_properties(self, PTB_TIMEDELTA): - vce = VideoChatEnded(duration=self.duration) + duration = VideoChatEnded(duration=self.duration).duration + if PTB_TIMEDELTA: - assert vce.duration == self.duration - assert isinstance(vce.duration, dtm.timedelta) + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) else: - assert vce.duration == int(self.duration.total_seconds()) - assert isinstance(vce.duration, int) + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) - @pytest.mark.parametrize("duration", [100, dtm.timedelta(seconds=100)]) - def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, duration): - video_chat_ended = VideoChatEnded(duration) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA): + VideoChatEnded(self.duration).duration - if isinstance(duration, int): + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: assert len(recwarn) == 1 assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning - else: - assert len(recwarn) == 0 - - warn_count = len(recwarn) - value = video_chat_ended.duration - - if not PTB_TIMEDELTA: - assert len(recwarn) == warn_count + 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[-1].message) - assert recwarn[-1].category is PTBDeprecationWarning - assert isinstance(value, (int, float)) - else: - assert len(recwarn) == warn_count - assert isinstance(value, dtm.timedelta) def test_equality(self): a = VideoChatEnded(100) From 319dd6c74ad002d3a8e74c323b22c8bf801c4777 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Fri, 16 May 2025 03:25:09 +0300 Subject: [PATCH 10/30] Accept timedeltas in params of `InlineQueryResult.*` classes. - InlineQueryResultGif.gif_duration - InlineQueryResultMpeg4Gif.mpeg4_duration - InlineQueryResultVideo.video_duration - InlineQueryResultAudio.audio_duration - InlineQueryResultVoice.voice_duration - InlineQueryResultLocation.live_period --- telegram/_inline/inlinequeryresultaudio.py | 43 ++++++++++++++---- telegram/_inline/inlinequeryresultgif.py | 43 ++++++++++++++---- telegram/_inline/inlinequeryresultlocation.py | 44 +++++++++++++++---- telegram/_inline/inlinequeryresultmpeg4gif.py | 43 ++++++++++++++---- telegram/_inline/inlinequeryresultvideo.py | 43 ++++++++++++++---- telegram/_inline/inlinequeryresultvoice.py | 43 ++++++++++++++---- tests/_inline/test_inlinequeryresultaudio.py | 33 +++++++++++--- tests/_inline/test_inlinequeryresultgif.py | 32 ++++++++++++-- .../_inline/test_inlinequeryresultlocation.py | 35 ++++++++++++--- .../_inline/test_inlinequeryresultmpeg4gif.py | 35 ++++++++++++--- tests/_inline/test_inlinequeryresultvideo.py | 34 +++++++++++--- tests/_inline/test_inlinequeryresultvoice.py | 33 +++++++++++--- tests/test_official/exceptions.py | 8 ++++ 13 files changed, 392 insertions(+), 77 deletions(-) diff --git a/telegram/_inline/inlinequeryresultaudio.py b/telegram/_inline/inlinequeryresultaudio.py index 8e3376a458f..eadd015d637 100644 --- a/telegram/_inline/inlinequeryresultaudio.py +++ b/telegram/_inline/inlinequeryresultaudio.py @@ -17,15 +17,17 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultAudio.""" +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -47,7 +49,11 @@ class InlineQueryResultAudio(InlineQueryResult): audio_url (:obj:`str`): A valid URL for the audio file. title (:obj:`str`): Title. performer (:obj:`str`, optional): Performer. - audio_duration (:obj:`str`, optional): Audio duration in seconds. + audio_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Audio duration + in seconds. + + .. versionchanged:: NEXT.VERSION + |time-period-input| caption (:obj:`str`, optional): Caption, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -69,7 +75,11 @@ class InlineQueryResultAudio(InlineQueryResult): audio_url (:obj:`str`): A valid URL for the audio file. title (:obj:`str`): Title. performer (:obj:`str`): Optional. Performer. - audio_duration (:obj:`str`): Optional. Audio duration in seconds. + audio_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Audio duration + in seconds. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| caption (:obj:`str`): Optional. Caption, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -88,7 +98,7 @@ class InlineQueryResultAudio(InlineQueryResult): """ __slots__ = ( - "audio_duration", + "_audio_duration", "audio_url", "caption", "caption_entities", @@ -105,7 +115,7 @@ def __init__( audio_url: str, title: str, performer: Optional[str] = None, - audio_duration: Optional[int] = None, + audio_duration: Optional[TimePeriod] = None, caption: Optional[str] = None, reply_markup: Optional[InlineKeyboardMarkup] = None, input_message_content: Optional["InputMessageContent"] = None, @@ -122,9 +132,26 @@ def __init__( # Optionals self.performer: Optional[str] = performer - self.audio_duration: Optional[int] = audio_duration + self._audio_duration: Optional[dtm.timedelta] = parse_period_arg(audio_duration) self.caption: Optional[str] = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup self.input_message_content: Optional[InputMessageContent] = input_message_content + + @property + def audio_duration(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._audio_duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._audio_duration is not None: + seconds = self._audio_duration.total_seconds() + out["audio_duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["audio_duration"] = self._audio_duration + return out diff --git a/telegram/_inline/inlinequeryresultgif.py b/telegram/_inline/inlinequeryresultgif.py index 398d61cc79a..8ad4c46edd0 100644 --- a/telegram/_inline/inlinequeryresultgif.py +++ b/telegram/_inline/inlinequeryresultgif.py @@ -17,15 +17,17 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultGif.""" +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -50,7 +52,11 @@ class InlineQueryResultGif(InlineQueryResult): gif_url (:obj:`str`): A valid URL for the GIF file. gif_width (:obj:`int`, optional): Width of the GIF. gif_height (:obj:`int`, optional): Height of the GIF. - gif_duration (:obj:`int`, optional): Duration of the GIF in seconds. + gif_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of the GIF + in seconds. + + .. versionchanged:: NEXT.VERSION + |time-period-input| thumbnail_url (:obj:`str`): URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the result. @@ -89,7 +95,11 @@ class InlineQueryResultGif(InlineQueryResult): gif_url (:obj:`str`): A valid URL for the GIF file. gif_width (:obj:`int`): Optional. Width of the GIF. gif_height (:obj:`int`): Optional. Height of the GIF. - gif_duration (:obj:`int`): Optional. Duration of the GIF in seconds. + gif_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Duration of the GIF + in seconds. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| thumbnail_url (:obj:`str`): URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the result. @@ -120,9 +130,9 @@ class InlineQueryResultGif(InlineQueryResult): """ __slots__ = ( + "_gif_duration", "caption", "caption_entities", - "gif_duration", "gif_height", "gif_url", "gif_width", @@ -146,7 +156,7 @@ def __init__( caption: Optional[str] = None, reply_markup: Optional[InlineKeyboardMarkup] = None, input_message_content: Optional["InputMessageContent"] = None, - gif_duration: Optional[int] = None, + gif_duration: Optional[TimePeriod] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Optional[Sequence[MessageEntity]] = None, thumbnail_mime_type: Optional[str] = None, @@ -163,7 +173,7 @@ def __init__( # Optionals self.gif_width: Optional[int] = gif_width self.gif_height: Optional[int] = gif_height - self.gif_duration: Optional[int] = gif_duration + self._gif_duration: Optional[dtm.timedelta] = parse_period_arg(gif_duration) self.title: Optional[str] = title self.caption: Optional[str] = caption self.parse_mode: ODVInput[str] = parse_mode @@ -172,3 +182,20 @@ def __init__( self.input_message_content: Optional[InputMessageContent] = input_message_content self.thumbnail_mime_type: Optional[str] = thumbnail_mime_type self.show_caption_above_media: Optional[bool] = show_caption_above_media + + @property + def gif_duration(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._gif_duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._gif_duration is not None: + seconds = self._gif_duration.total_seconds() + out["gif_duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["gif_duration"] = self._gif_duration + return out diff --git a/telegram/_inline/inlinequeryresultlocation.py b/telegram/_inline/inlinequeryresultlocation.py index 01035537840..c3a4cdcd3c7 100644 --- a/telegram/_inline/inlinequeryresultlocation.py +++ b/telegram/_inline/inlinequeryresultlocation.py @@ -18,12 +18,15 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultLocation.""" -from typing import TYPE_CHECKING, Final, Optional +import datetime as dtm +from typing import TYPE_CHECKING, Final, Optional, Union from telegram import constants from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod if TYPE_CHECKING: from telegram import InputMessageContent @@ -48,10 +51,13 @@ class InlineQueryResultLocation(InlineQueryResult): horizontal_accuracy (:obj:`float`, optional): The radius of uncertainty for the location, measured in meters; 0- :tg-const:`telegram.InlineQueryResultLocation.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`, optional): Period in seconds for which the location will be - updated, should be between + live_period (:obj:`int` | :class:`datetime.timedelta`, optional): Period in seconds for + which the location will be updated, should be between :tg-const:`telegram.InlineQueryResultLocation.MIN_LIVE_PERIOD` and :tg-const:`telegram.InlineQueryResultLocation.MAX_LIVE_PERIOD`. + + .. versionchanged:: NEXT.VERSION + |time-period-input| heading (:obj:`int`, optional): For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InlineQueryResultLocation.MIN_HEADING` and @@ -86,12 +92,15 @@ class InlineQueryResultLocation(InlineQueryResult): horizontal_accuracy (:obj:`float`): Optional. The radius of uncertainty for the location, measured in meters; 0- :tg-const:`telegram.InlineQueryResultLocation.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`): Optional. Period in seconds for which the location will be - updated, should be between + live_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Period in seconds for + which the location will be updated, should be between :tg-const:`telegram.InlineQueryResultLocation.MIN_LIVE_PERIOD` and :tg-const:`telegram.InlineQueryResultLocation.MAX_LIVE_PERIOD` or :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` for live locations that can be edited indefinitely. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| heading (:obj:`int`): Optional. For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InlineQueryResultLocation.MIN_HEADING` and @@ -118,11 +127,11 @@ class InlineQueryResultLocation(InlineQueryResult): """ __slots__ = ( + "_live_period", "heading", "horizontal_accuracy", "input_message_content", "latitude", - "live_period", "longitude", "proximity_alert_radius", "reply_markup", @@ -138,7 +147,7 @@ def __init__( latitude: float, longitude: float, title: str, - live_period: Optional[int] = None, + live_period: Optional[TimePeriod] = None, reply_markup: Optional[InlineKeyboardMarkup] = None, input_message_content: Optional["InputMessageContent"] = None, horizontal_accuracy: Optional[float] = None, @@ -158,7 +167,7 @@ def __init__( self.title: str = title # Optionals - self.live_period: Optional[int] = live_period + self._live_period: Optional[dtm.timedelta] = parse_period_arg(live_period) self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup self.input_message_content: Optional[InputMessageContent] = input_message_content self.thumbnail_url: Optional[str] = thumbnail_url @@ -170,6 +179,23 @@ def __init__( int(proximity_alert_radius) if proximity_alert_radius else None ) + @property + def live_period(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._live_period) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._live_period is not None: + seconds = self._live_period.total_seconds() + out["live_period"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["live_period"] = self._live_period + return out + HORIZONTAL_ACCURACY: Final[int] = constants.LocationLimit.HORIZONTAL_ACCURACY """:const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY` diff --git a/telegram/_inline/inlinequeryresultmpeg4gif.py b/telegram/_inline/inlinequeryresultmpeg4gif.py index b47faa0186a..c55f91c58f2 100644 --- a/telegram/_inline/inlinequeryresultmpeg4gif.py +++ b/telegram/_inline/inlinequeryresultmpeg4gif.py @@ -17,15 +17,17 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultMpeg4Gif.""" +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -51,7 +53,11 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult): mpeg4_url (:obj:`str`): A valid URL for the MP4 file. mpeg4_width (:obj:`int`, optional): Video width. mpeg4_height (:obj:`int`, optional): Video height. - mpeg4_duration (:obj:`int`, optional): Video duration in seconds. + mpeg4_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Video duration + in seconds. + + .. versionchanged:: NEXT.VERSION + |time-period-input| thumbnail_url (:obj:`str`): URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the result. @@ -91,7 +97,11 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult): mpeg4_url (:obj:`str`): A valid URL for the MP4 file. mpeg4_width (:obj:`int`): Optional. Video width. mpeg4_height (:obj:`int`): Optional. Video height. - mpeg4_duration (:obj:`int`): Optional. Video duration in seconds. + mpeg4_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Video duration + in seconds. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| thumbnail_url (:obj:`str`): URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the result. @@ -122,10 +132,10 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult): """ __slots__ = ( + "_mpeg4_duration", "caption", "caption_entities", "input_message_content", - "mpeg4_duration", "mpeg4_height", "mpeg4_url", "mpeg4_width", @@ -148,7 +158,7 @@ def __init__( caption: Optional[str] = None, reply_markup: Optional[InlineKeyboardMarkup] = None, input_message_content: Optional["InputMessageContent"] = None, - mpeg4_duration: Optional[int] = None, + mpeg4_duration: Optional[TimePeriod] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Optional[Sequence[MessageEntity]] = None, thumbnail_mime_type: Optional[str] = None, @@ -165,7 +175,7 @@ def __init__( # Optional self.mpeg4_width: Optional[int] = mpeg4_width self.mpeg4_height: Optional[int] = mpeg4_height - self.mpeg4_duration: Optional[int] = mpeg4_duration + self._mpeg4_duration: Optional[dtm.timedelta] = parse_period_arg(mpeg4_duration) self.title: Optional[str] = title self.caption: Optional[str] = caption self.parse_mode: ODVInput[str] = parse_mode @@ -174,3 +184,20 @@ def __init__( self.input_message_content: Optional[InputMessageContent] = input_message_content self.thumbnail_mime_type: Optional[str] = thumbnail_mime_type self.show_caption_above_media: Optional[bool] = show_caption_above_media + + @property + def mpeg4_duration(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._mpeg4_duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._mpeg4_duration is not None: + seconds = self._mpeg4_duration.total_seconds() + out["mpeg4_duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["mpeg4_duration"] = self._mpeg4_duration + return out diff --git a/telegram/_inline/inlinequeryresultvideo.py b/telegram/_inline/inlinequeryresultvideo.py index edc6ce343ac..0a76863aaff 100644 --- a/telegram/_inline/inlinequeryresultvideo.py +++ b/telegram/_inline/inlinequeryresultvideo.py @@ -17,15 +17,17 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultVideo.""" +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -73,7 +75,11 @@ class InlineQueryResultVideo(InlineQueryResult): video_width (:obj:`int`, optional): Video width. video_height (:obj:`int`, optional): Video height. - video_duration (:obj:`int`, optional): Video duration in seconds. + video_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Video duration + in seconds. + + .. versionchanged:: NEXT.VERSION + |time-period-input| description (:obj:`str`, optional): Short description of the result. reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached to the message. @@ -110,7 +116,11 @@ class InlineQueryResultVideo(InlineQueryResult): video_width (:obj:`int`): Optional. Video width. video_height (:obj:`int`): Optional. Video height. - video_duration (:obj:`int`): Optional. Video duration in seconds. + video_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Video duration + in seconds. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| description (:obj:`str`): Optional. Short description of the result. reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached to the message. @@ -125,6 +135,7 @@ class InlineQueryResultVideo(InlineQueryResult): """ __slots__ = ( + "_video_duration", "caption", "caption_entities", "description", @@ -135,7 +146,6 @@ class InlineQueryResultVideo(InlineQueryResult): "show_caption_above_media", "thumbnail_url", "title", - "video_duration", "video_height", "video_url", "video_width", @@ -151,7 +161,7 @@ def __init__( caption: Optional[str] = None, video_width: Optional[int] = None, video_height: Optional[int] = None, - video_duration: Optional[int] = None, + video_duration: Optional[TimePeriod] = None, description: Optional[str] = None, reply_markup: Optional[InlineKeyboardMarkup] = None, input_message_content: Optional["InputMessageContent"] = None, @@ -175,8 +185,25 @@ def __init__( self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) self.video_width: Optional[int] = video_width self.video_height: Optional[int] = video_height - self.video_duration: Optional[int] = video_duration + self._video_duration: Optional[dtm.timedelta] = parse_period_arg(video_duration) self.description: Optional[str] = description self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup self.input_message_content: Optional[InputMessageContent] = input_message_content self.show_caption_above_media: Optional[bool] = show_caption_above_media + + @property + def video_duration(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._video_duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._video_duration is not None: + seconds = self._video_duration.total_seconds() + out["video_duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["video_duration"] = self._video_duration + return out diff --git a/telegram/_inline/inlinequeryresultvoice.py b/telegram/_inline/inlinequeryresultvoice.py index b798040b1aa..c3b8f46604a 100644 --- a/telegram/_inline/inlinequeryresultvoice.py +++ b/telegram/_inline/inlinequeryresultvoice.py @@ -17,15 +17,17 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultVoice.""" +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -56,7 +58,11 @@ class InlineQueryResultVoice(InlineQueryResult): .. versionchanged:: 20.0 |sequenceclassargs| - voice_duration (:obj:`int`, optional): Recording duration in seconds. + voice_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Recording duration + in seconds. + + .. versionchanged:: NEXT.VERSION + |time-period-input| reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the @@ -79,7 +85,11 @@ class InlineQueryResultVoice(InlineQueryResult): * |tupleclassattrs| * |alwaystuple| - voice_duration (:obj:`int`): Optional. Recording duration in seconds. + voice_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Recording duration + in seconds. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached to the message. input_message_content (:class:`telegram.InputMessageContent`): Optional. Content of the @@ -88,13 +98,13 @@ class InlineQueryResultVoice(InlineQueryResult): """ __slots__ = ( + "_voice_duration", "caption", "caption_entities", "input_message_content", "parse_mode", "reply_markup", "title", - "voice_duration", "voice_url", ) @@ -103,7 +113,7 @@ def __init__( id: str, # pylint: disable=redefined-builtin voice_url: str, title: str, - voice_duration: Optional[int] = None, + voice_duration: Optional[TimePeriod] = None, caption: Optional[str] = None, reply_markup: Optional[InlineKeyboardMarkup] = None, input_message_content: Optional["InputMessageContent"] = None, @@ -119,9 +129,26 @@ def __init__( self.title: str = title # Optional - self.voice_duration: Optional[int] = voice_duration + self._voice_duration: Optional[dtm.timedelta] = parse_period_arg(voice_duration) self.caption: Optional[str] = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup self.input_message_content: Optional[InputMessageContent] = input_message_content + + @property + def voice_duration(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._voice_duration) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._voice_duration is not None: + seconds = self._voice_duration.total_seconds() + out["voice_duration"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["voice_duration"] = self._voice_duration + return out diff --git a/tests/_inline/test_inlinequeryresultaudio.py b/tests/_inline/test_inlinequeryresultaudio.py index 4c781655910..31a7d027422 100644 --- a/tests/_inline/test_inlinequeryresultaudio.py +++ b/tests/_inline/test_inlinequeryresultaudio.py @@ -17,6 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import ( @@ -27,6 +29,7 @@ InputTextMessageContent, MessageEntity, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -52,7 +55,7 @@ class InlineQueryResultAudioTestBase: audio_url = "audio url" title = "title" performer = "performer" - audio_duration = "audio_duration" + audio_duration = dtm.timedelta(seconds=10) caption = "caption" parse_mode = "Markdown" caption_entities = [MessageEntity(MessageEntity.ITALIC, 0, 7)] @@ -73,7 +76,7 @@ def test_expected_values(self, inline_query_result_audio): assert inline_query_result_audio.audio_url == self.audio_url assert inline_query_result_audio.title == self.title assert inline_query_result_audio.performer == self.performer - assert inline_query_result_audio.audio_duration == self.audio_duration + assert inline_query_result_audio._audio_duration == self.audio_duration assert inline_query_result_audio.caption == self.caption assert inline_query_result_audio.parse_mode == self.parse_mode assert inline_query_result_audio.caption_entities == tuple(self.caption_entities) @@ -92,10 +95,10 @@ def test_to_dict(self, inline_query_result_audio): assert inline_query_result_audio_dict["audio_url"] == inline_query_result_audio.audio_url assert inline_query_result_audio_dict["title"] == inline_query_result_audio.title assert inline_query_result_audio_dict["performer"] == inline_query_result_audio.performer - assert ( - inline_query_result_audio_dict["audio_duration"] - == inline_query_result_audio.audio_duration + assert inline_query_result_audio_dict["audio_duration"] == int( + self.audio_duration.total_seconds() ) + assert isinstance(inline_query_result_audio_dict["audio_duration"], int) assert inline_query_result_audio_dict["caption"] == inline_query_result_audio.caption assert inline_query_result_audio_dict["parse_mode"] == inline_query_result_audio.parse_mode assert inline_query_result_audio_dict["caption_entities"] == [ @@ -114,6 +117,26 @@ def test_caption_entities_always_tuple(self): inline_query_result_audio = InlineQueryResultAudio(self.id_, self.audio_url, self.title) assert inline_query_result_audio.caption_entities == () + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_audio): + audio_duration = inline_query_result_audio.audio_duration + + if PTB_TIMEDELTA: + assert audio_duration == self.audio_duration + assert isinstance(audio_duration, dtm.timedelta) + else: + assert audio_duration == int(self.audio_duration.total_seconds()) + assert isinstance(audio_duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_result_audio): + inline_query_result_audio.audio_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InlineQueryResultAudio(self.id_, self.audio_url, self.title) b = InlineQueryResultAudio(self.id_, self.title, self.title) diff --git a/tests/_inline/test_inlinequeryresultgif.py b/tests/_inline/test_inlinequeryresultgif.py index 878b9b61d3c..5bcdda388fb 100644 --- a/tests/_inline/test_inlinequeryresultgif.py +++ b/tests/_inline/test_inlinequeryresultgif.py @@ -16,6 +16,8 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import ( @@ -26,6 +28,7 @@ InputTextMessageContent, MessageEntity, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -55,7 +58,7 @@ class InlineQueryResultGifTestBase: gif_url = "gif url" gif_width = 10 gif_height = 15 - gif_duration = 1 + gif_duration = dtm.timedelta(seconds=1) thumbnail_url = "thumb url" thumbnail_mime_type = "image/jpeg" title = "title" @@ -84,7 +87,7 @@ def test_expected_values(self, inline_query_result_gif): assert inline_query_result_gif.gif_url == self.gif_url assert inline_query_result_gif.gif_width == self.gif_width assert inline_query_result_gif.gif_height == self.gif_height - assert inline_query_result_gif.gif_duration == self.gif_duration + assert inline_query_result_gif._gif_duration == self.gif_duration assert inline_query_result_gif.thumbnail_url == self.thumbnail_url assert inline_query_result_gif.thumbnail_mime_type == self.thumbnail_mime_type assert inline_query_result_gif.title == self.title @@ -107,7 +110,10 @@ def test_to_dict(self, inline_query_result_gif): assert inline_query_result_gif_dict["gif_url"] == inline_query_result_gif.gif_url assert inline_query_result_gif_dict["gif_width"] == inline_query_result_gif.gif_width assert inline_query_result_gif_dict["gif_height"] == inline_query_result_gif.gif_height - assert inline_query_result_gif_dict["gif_duration"] == inline_query_result_gif.gif_duration + assert inline_query_result_gif_dict["gif_duration"] == int( + self.gif_duration.total_seconds() + ) + assert isinstance(inline_query_result_gif_dict["gif_duration"], int) assert ( inline_query_result_gif_dict["thumbnail_url"] == inline_query_result_gif.thumbnail_url ) @@ -134,6 +140,26 @@ def test_to_dict(self, inline_query_result_gif): == inline_query_result_gif.show_caption_above_media ) + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_gif): + gif_duration = inline_query_result_gif.gif_duration + + if PTB_TIMEDELTA: + assert gif_duration == self.gif_duration + assert isinstance(gif_duration, dtm.timedelta) + else: + assert gif_duration == int(self.gif_duration.total_seconds()) + assert isinstance(gif_duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_result_gif): + inline_query_result_gif.gif_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InlineQueryResultGif(self.id_, self.gif_url, self.thumbnail_url) b = InlineQueryResultGif(self.id_, self.gif_url, self.thumbnail_url) diff --git a/tests/_inline/test_inlinequeryresultlocation.py b/tests/_inline/test_inlinequeryresultlocation.py index db9c64cfd10..9cc97fcf28d 100644 --- a/tests/_inline/test_inlinequeryresultlocation.py +++ b/tests/_inline/test_inlinequeryresultlocation.py @@ -16,6 +16,8 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import ( @@ -25,6 +27,7 @@ InlineQueryResultVoice, InputTextMessageContent, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -54,7 +57,7 @@ class InlineQueryResultLocationTestBase: longitude = 1.0 title = "title" horizontal_accuracy = 999 - live_period = 70 + live_period = dtm.timedelta(seconds=70) heading = 90 proximity_alert_radius = 1000 thumbnail_url = "thumb url" @@ -77,7 +80,7 @@ def test_expected_values(self, inline_query_result_location): assert inline_query_result_location.latitude == self.latitude assert inline_query_result_location.longitude == self.longitude assert inline_query_result_location.title == self.title - assert inline_query_result_location.live_period == self.live_period + assert inline_query_result_location._live_period == self.live_period assert inline_query_result_location.thumbnail_url == self.thumbnail_url assert inline_query_result_location.thumbnail_width == self.thumbnail_width assert inline_query_result_location.thumbnail_height == self.thumbnail_height @@ -104,10 +107,10 @@ def test_to_dict(self, inline_query_result_location): == inline_query_result_location.longitude ) assert inline_query_result_location_dict["title"] == inline_query_result_location.title - assert ( - inline_query_result_location_dict["live_period"] - == inline_query_result_location.live_period + assert inline_query_result_location_dict["live_period"] == int( + self.live_period.total_seconds() ) + assert isinstance(inline_query_result_location_dict["live_period"], int) assert ( inline_query_result_location_dict["thumbnail_url"] == inline_query_result_location.thumbnail_url @@ -138,6 +141,28 @@ def test_to_dict(self, inline_query_result_location): == inline_query_result_location.proximity_alert_radius ) + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_location): + live_period = inline_query_result_location.live_period + + if PTB_TIMEDELTA: + assert live_period == self.live_period + assert isinstance(live_period, dtm.timedelta) + else: + assert live_period == int(self.live_period.total_seconds()) + assert isinstance(live_period, int) + + def test_time_period_int_deprecated( + self, recwarn, PTB_TIMEDELTA, inline_query_result_location + ): + inline_query_result_location.live_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InlineQueryResultLocation(self.id_, self.longitude, self.latitude, self.title) b = InlineQueryResultLocation(self.id_, self.longitude, self.latitude, self.title) diff --git a/tests/_inline/test_inlinequeryresultmpeg4gif.py b/tests/_inline/test_inlinequeryresultmpeg4gif.py index 03b6ca991d1..cf666316fb5 100644 --- a/tests/_inline/test_inlinequeryresultmpeg4gif.py +++ b/tests/_inline/test_inlinequeryresultmpeg4gif.py @@ -16,6 +16,8 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import ( @@ -26,6 +28,7 @@ InputTextMessageContent, MessageEntity, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -55,7 +58,7 @@ class InlineQueryResultMpeg4GifTestBase: mpeg4_url = "mpeg4 url" mpeg4_width = 10 mpeg4_height = 15 - mpeg4_duration = 1 + mpeg4_duration = dtm.timedelta(seconds=1) thumbnail_url = "thumb url" thumbnail_mime_type = "image/jpeg" title = "title" @@ -80,7 +83,7 @@ def test_expected_values(self, inline_query_result_mpeg4_gif): assert inline_query_result_mpeg4_gif.mpeg4_url == self.mpeg4_url assert inline_query_result_mpeg4_gif.mpeg4_width == self.mpeg4_width assert inline_query_result_mpeg4_gif.mpeg4_height == self.mpeg4_height - assert inline_query_result_mpeg4_gif.mpeg4_duration == self.mpeg4_duration + assert inline_query_result_mpeg4_gif._mpeg4_duration == self.mpeg4_duration assert inline_query_result_mpeg4_gif.thumbnail_url == self.thumbnail_url assert inline_query_result_mpeg4_gif.thumbnail_mime_type == self.thumbnail_mime_type assert inline_query_result_mpeg4_gif.title == self.title @@ -118,10 +121,10 @@ def test_to_dict(self, inline_query_result_mpeg4_gif): inline_query_result_mpeg4_gif_dict["mpeg4_height"] == inline_query_result_mpeg4_gif.mpeg4_height ) - assert ( - inline_query_result_mpeg4_gif_dict["mpeg4_duration"] - == inline_query_result_mpeg4_gif.mpeg4_duration + assert inline_query_result_mpeg4_gif_dict["mpeg4_duration"] == int( + self.mpeg4_duration.total_seconds() ) + assert isinstance(inline_query_result_mpeg4_gif_dict["mpeg4_duration"], int) assert ( inline_query_result_mpeg4_gif_dict["thumbnail_url"] == inline_query_result_mpeg4_gif.thumbnail_url @@ -154,6 +157,28 @@ def test_to_dict(self, inline_query_result_mpeg4_gif): == inline_query_result_mpeg4_gif.show_caption_above_media ) + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_mpeg4_gif): + mpeg4_duration = inline_query_result_mpeg4_gif.mpeg4_duration + + if PTB_TIMEDELTA: + assert mpeg4_duration == self.mpeg4_duration + assert isinstance(mpeg4_duration, dtm.timedelta) + else: + assert mpeg4_duration == int(self.mpeg4_duration.total_seconds()) + assert isinstance(mpeg4_duration, int) + + def test_time_period_int_deprecated( + self, recwarn, PTB_TIMEDELTA, inline_query_result_mpeg4_gif + ): + inline_query_result_mpeg4_gif.mpeg4_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InlineQueryResultMpeg4Gif(self.id_, self.mpeg4_url, self.thumbnail_url) b = InlineQueryResultMpeg4Gif(self.id_, self.mpeg4_url, self.thumbnail_url) diff --git a/tests/_inline/test_inlinequeryresultvideo.py b/tests/_inline/test_inlinequeryresultvideo.py index d165d9af3f2..7c040cd5763 100644 --- a/tests/_inline/test_inlinequeryresultvideo.py +++ b/tests/_inline/test_inlinequeryresultvideo.py @@ -16,6 +16,8 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import ( @@ -26,6 +28,7 @@ InputTextMessageContent, MessageEntity, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -57,7 +60,7 @@ class InlineQueryResultVideoTestBase: mime_type = "mime type" video_width = 10 video_height = 15 - video_duration = 15 + video_duration = dtm.timedelta(seconds=15) thumbnail_url = "thumbnail url" title = "title" caption = "caption" @@ -83,7 +86,7 @@ def test_expected_values(self, inline_query_result_video): assert inline_query_result_video.mime_type == self.mime_type assert inline_query_result_video.video_width == self.video_width assert inline_query_result_video.video_height == self.video_height - assert inline_query_result_video.video_duration == self.video_duration + assert inline_query_result_video._video_duration == self.video_duration assert inline_query_result_video.thumbnail_url == self.thumbnail_url assert inline_query_result_video.title == self.title assert inline_query_result_video.description == self.description @@ -118,10 +121,10 @@ def test_to_dict(self, inline_query_result_video): inline_query_result_video_dict["video_height"] == inline_query_result_video.video_height ) - assert ( - inline_query_result_video_dict["video_duration"] - == inline_query_result_video.video_duration + assert inline_query_result_video_dict["video_duration"] == int( + self.video_duration.total_seconds() ) + assert isinstance(inline_query_result_video_dict["video_duration"], int) assert ( inline_query_result_video_dict["thumbnail_url"] == inline_query_result_video.thumbnail_url @@ -148,6 +151,27 @@ def test_to_dict(self, inline_query_result_video): == inline_query_result_video.show_caption_above_media ) + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_video): + iqrv = inline_query_result_video + if PTB_TIMEDELTA: + assert iqrv.video_duration == self.video_duration + assert isinstance(iqrv.video_duration, dtm.timedelta) + else: + assert iqrv.video_duration == int(self.video_duration.total_seconds()) + assert isinstance(iqrv.video_duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_result_video): + value = inline_query_result_video.video_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + assert isinstance(value, dtm.timedelta) + else: + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + assert isinstance(value, int) + def test_equality(self): a = InlineQueryResultVideo( self.id_, self.video_url, self.mime_type, self.thumbnail_url, self.title diff --git a/tests/_inline/test_inlinequeryresultvoice.py b/tests/_inline/test_inlinequeryresultvoice.py index 01662700c74..1f7fe47cda4 100644 --- a/tests/_inline/test_inlinequeryresultvoice.py +++ b/tests/_inline/test_inlinequeryresultvoice.py @@ -16,6 +16,8 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import ( @@ -26,6 +28,7 @@ InputTextMessageContent, MessageEntity, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -49,7 +52,7 @@ class InlineQueryResultVoiceTestBase: type_ = "voice" voice_url = "voice url" title = "title" - voice_duration = "voice_duration" + voice_duration = dtm.timedelta(seconds=10) caption = "caption" parse_mode = "HTML" caption_entities = [MessageEntity(MessageEntity.ITALIC, 0, 7)] @@ -69,7 +72,7 @@ def test_expected_values(self, inline_query_result_voice): assert inline_query_result_voice.id == self.id_ assert inline_query_result_voice.voice_url == self.voice_url assert inline_query_result_voice.title == self.title - assert inline_query_result_voice.voice_duration == self.voice_duration + assert inline_query_result_voice._voice_duration == self.voice_duration assert inline_query_result_voice.caption == self.caption assert inline_query_result_voice.parse_mode == self.parse_mode assert inline_query_result_voice.caption_entities == tuple(self.caption_entities) @@ -96,10 +99,10 @@ def test_to_dict(self, inline_query_result_voice): assert inline_query_result_voice_dict["id"] == inline_query_result_voice.id assert inline_query_result_voice_dict["voice_url"] == inline_query_result_voice.voice_url assert inline_query_result_voice_dict["title"] == inline_query_result_voice.title - assert ( - inline_query_result_voice_dict["voice_duration"] - == inline_query_result_voice.voice_duration + assert inline_query_result_voice_dict["voice_duration"] == int( + self.voice_duration.total_seconds() ) + assert isinstance(inline_query_result_voice_dict["voice_duration"], int) assert inline_query_result_voice_dict["caption"] == inline_query_result_voice.caption assert inline_query_result_voice_dict["parse_mode"] == inline_query_result_voice.parse_mode assert inline_query_result_voice_dict["caption_entities"] == [ @@ -114,6 +117,26 @@ def test_to_dict(self, inline_query_result_voice): == inline_query_result_voice.reply_markup.to_dict() ) + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_voice): + voice_duration = inline_query_result_voice.voice_duration + + if PTB_TIMEDELTA: + assert voice_duration == self.voice_duration + assert isinstance(voice_duration, dtm.timedelta) + else: + assert voice_duration == int(self.voice_duration.total_seconds()) + assert isinstance(voice_duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_result_voice): + inline_query_result_voice.voice_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InlineQueryResultVoice(self.id_, self.voice_url, self.title) b = InlineQueryResultVoice(self.id_, self.voice_url, self.title) diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index ec4ef5850e9..796f412f2e5 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -121,6 +121,14 @@ class ParamTypeCheckingExceptions: "|Input(Paid)?Media(Audio|Video|Animation)": { "duration": int, # actual: Union[int, dtm.timedelta] }, + "InlineQueryResult.*": { + "live_period": int, # actual: Union[int, dtm.timedelta] + "voice_duration": int, # actual: Union[int, dtm.timedelta] + "audio_duration": int, # actual: Union[int, dtm.timedelta] + "video_duration": int, # actual: Union[int, dtm.timedelta] + "mpeg4_duration": int, # actual: Union[int, dtm.timedelta] + "gif_duration": int, # actual: Union[int, dtm.timedelta] + }, "EncryptedPassportElement": { "data": str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress] }, From d8dbe15a3549c1a3fecce29e79b244e34d25d298 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sat, 17 May 2025 06:03:41 +0300 Subject: [PATCH 11/30] Accept timedeltas in other time period params. - Video.start_timestamp - Poll.open_period - Location.live_period - MessageAutoDeleteTimerChanged.message_auto_delete_time - ChatInviteLink.subscription_period - InputLocationMessageContent.live_period --- telegram/_chatinvitelink.py | 52 ++++++++++++---- telegram/_files/location.py | 58 +++++++++++++++--- telegram/_files/video.py | 42 +++++++++---- .../_inline/inputlocationmessagecontent.py | 47 +++++++++++--- telegram/_messageautodeletetimerchanged.py | 61 ++++++++++++++++--- telegram/_poll.py | 55 +++++++++++++---- tests/_files/test_location.py | 28 +++++++-- tests/_files/test_video.py | 32 +++++++--- .../test_inputlocationmessagecontent.py | 35 +++++++++-- tests/test_chatinvitelink.py | 30 +++++++-- tests/test_messageautodeletetimerchanged.py | 38 ++++++++++-- tests/test_official/exceptions.py | 13 +++- tests/test_poll.py | 29 +++++++-- 13 files changed, 428 insertions(+), 92 deletions(-) diff --git a/telegram/_chatinvitelink.py b/telegram/_chatinvitelink.py index 289ee48bdba..e159c8cd9ad 100644 --- a/telegram/_chatinvitelink.py +++ b/telegram/_chatinvitelink.py @@ -18,13 +18,17 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents an invite link for a chat.""" import datetime as dtm -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from telegram._telegramobject import TelegramObject from telegram._user import User -from telegram._utils.argumentparsing import de_json_optional -from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import de_json_optional, parse_period_arg +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_timedelta_value, +) +from telegram._utils.types import JSONDict, TimePeriod if TYPE_CHECKING: from telegram import Bot @@ -70,10 +74,13 @@ class ChatInviteLink(TelegramObject): created using this link. .. versionadded:: 13.8 - subscription_period (:obj:`int`, optional): The number of seconds the subscription will be - active for before the next payment. + subscription_period (:obj:`int` | :class:`datetime.timedelta`, optional): The number of + seconds the subscription will be active for before the next payment. .. versionadded:: 21.5 + + .. versionchanged:: NEXT.VERSION + |time-period-input| subscription_price (:obj:`int`, optional): The amount of Telegram Stars a user must pay initially and after each subsequent subscription period to be a member of the chat using the link. @@ -107,10 +114,13 @@ class ChatInviteLink(TelegramObject): created using this link. .. versionadded:: 13.8 - subscription_period (:obj:`int`): Optional. The number of seconds the subscription will be - active for before the next payment. + subscription_period (:obj:`int` | :class:`datetime.timedelta`): Optional. The number of + seconds the subscription will be active for before the next payment. .. versionadded:: 21.5 + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| subscription_price (:obj:`int`): Optional. The amount of Telegram Stars a user must pay initially and after each subsequent subscription period to be a member of the chat using the link. @@ -120,6 +130,7 @@ class ChatInviteLink(TelegramObject): """ __slots__ = ( + "_subscription_period", "creates_join_request", "creator", "expire_date", @@ -129,7 +140,6 @@ class ChatInviteLink(TelegramObject): "member_limit", "name", "pending_join_request_count", - "subscription_period", "subscription_price", ) @@ -144,7 +154,7 @@ def __init__( member_limit: Optional[int] = None, name: Optional[str] = None, pending_join_request_count: Optional[int] = None, - subscription_period: Optional[int] = None, + subscription_period: Optional[TimePeriod] = None, subscription_price: Optional[int] = None, *, api_kwargs: Optional[JSONDict] = None, @@ -164,7 +174,7 @@ def __init__( self.pending_join_request_count: Optional[int] = ( int(pending_join_request_count) if pending_join_request_count is not None else None ) - self.subscription_period: Optional[int] = subscription_period + self._subscription_period: Optional[dtm.timedelta] = parse_period_arg(subscription_period) self.subscription_price: Optional[int] = subscription_price self._id_attrs = ( @@ -177,6 +187,13 @@ def __init__( self._freeze() + @property + def subscription_period(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._subscription_period) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatInviteLink": """See :meth:`telegram.TelegramObject.de_json`.""" @@ -187,5 +204,18 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatInviteLink data["creator"] = de_json_optional(data.get("creator"), User, bot) data["expire_date"] = from_timestamp(data.get("expire_date", None), tzinfo=loc_tzinfo) + data["subscription_period"] = ( + dtm.timedelta(seconds=s) if (s := data.get("subscription_period")) else None + ) return super().de_json(data=data, bot=bot) + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._subscription_period is not None: + seconds = self._subscription_period.total_seconds() + out["subscription_period"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["subscription_period"] = self._subscription_period + return out diff --git a/telegram/_files/location.py b/telegram/_files/location.py index 87c895b711a..565faee6bb6 100644 --- a/telegram/_files/location.py +++ b/telegram/_files/location.py @@ -18,11 +18,17 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Location.""" -from typing import Final, Optional +import datetime as dtm +from typing import TYPE_CHECKING, Final, Optional, Union from telegram import constants from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod + +if TYPE_CHECKING: + from telegram import Bot class Location(TelegramObject): @@ -36,8 +42,12 @@ class Location(TelegramObject): latitude (:obj:`float`): Latitude as defined by the sender. horizontal_accuracy (:obj:`float`, optional): The radius of uncertainty for the location, measured in meters; 0-:tg-const:`telegram.Location.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`, optional): Time relative to the message sending date, during which - the location can be updated, in seconds. For active live locations only. + live_period (:obj:`int` | :class:`datetime.timedelta`, optional): Time relative to the + message sending date, during which the location can be updated, in seconds. For active + live locations only. + + .. versionchanged:: NEXT.VERSION + |time-period-input| heading (:obj:`int`, optional): The direction in which user is moving, in degrees; :tg-const:`telegram.Location.MIN_HEADING`-:tg-const:`telegram.Location.MAX_HEADING`. For active live locations only. @@ -49,8 +59,12 @@ class Location(TelegramObject): latitude (:obj:`float`): Latitude as defined by the sender. horizontal_accuracy (:obj:`float`): Optional. The radius of uncertainty for the location, measured in meters; 0-:tg-const:`telegram.Location.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`): Optional. Time relative to the message sending date, during which - the location can be updated, in seconds. For active live locations only. + live_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Time relative to the + message sending date, during which the location can be updated, in seconds. For active + live locations only. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| heading (:obj:`int`): Optional. The direction in which user is moving, in degrees; :tg-const:`telegram.Location.MIN_HEADING`-:tg-const:`telegram.Location.MAX_HEADING`. For active live locations only. @@ -60,10 +74,10 @@ class Location(TelegramObject): """ __slots__ = ( + "_live_period", "heading", "horizontal_accuracy", "latitude", - "live_period", "longitude", "proximity_alert_radius", ) @@ -73,7 +87,7 @@ def __init__( longitude: float, latitude: float, horizontal_accuracy: Optional[float] = None, - live_period: Optional[int] = None, + live_period: Optional[TimePeriod] = None, heading: Optional[int] = None, proximity_alert_radius: Optional[int] = None, *, @@ -86,7 +100,7 @@ def __init__( # Optionals self.horizontal_accuracy: Optional[float] = horizontal_accuracy - self.live_period: Optional[int] = live_period + self._live_period: Optional[dtm.timedelta] = parse_period_arg(live_period) self.heading: Optional[int] = heading self.proximity_alert_radius: Optional[int] = ( int(proximity_alert_radius) if proximity_alert_radius else None @@ -96,6 +110,32 @@ def __init__( self._freeze() + @property + def live_period(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._live_period) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Location": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["live_period"] = dtm.timedelta(seconds=s) if (s := data.get("live_period")) else None + + return super().de_json(data=data, bot=bot) + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._live_period is not None: + seconds = self._live_period.total_seconds() + out["live_period"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["live_period"] = self._live_period + return out + HORIZONTAL_ACCURACY: Final[int] = constants.LocationLimit.HORIZONTAL_ACCURACY """:const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY` diff --git a/telegram/_files/video.py b/telegram/_files/video.py index 74c52535a09..a9c510fbbf0 100644 --- a/telegram/_files/video.py +++ b/telegram/_files/video.py @@ -63,10 +63,13 @@ class Video(_BaseThumbedMedium): the video in the message. .. versionadded:: 21.11 - start_timestamp (:obj:`int`, optional): Timestamp in seconds from which the video - will play in the message + start_timestamp (:obj:`int` | :class:`datetime.timedelta`, optional): Timestamp in seconds + from which the video will play in the message .. versionadded:: 21.11 + .. versionchanged:: NEXT.VERSION + |time-period-input| + Attributes: file_id (:obj:`str`): Identifier for this file, which can be used to download or reuse the file. @@ -90,18 +93,21 @@ class Video(_BaseThumbedMedium): the video in the message. .. versionadded:: 21.11 - start_timestamp (:obj:`int`): Optional, Timestamp in seconds from which the video - will play in the message + start_timestamp (:obj:`int` | :class:`datetime.timedelta`): Optional, Timestamp in seconds + from which the video will play in the message .. versionadded:: 21.11 + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| """ __slots__ = ( "_duration", + "_start_timestamp", "cover", "file_name", "height", "mime_type", - "start_timestamp", "width", ) @@ -117,7 +123,7 @@ def __init__( file_name: Optional[str] = None, thumbnail: Optional[PhotoSize] = None, cover: Optional[Sequence[PhotoSize]] = None, - start_timestamp: Optional[int] = None, + start_timestamp: Optional[TimePeriod] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -137,7 +143,7 @@ def __init__( self.mime_type: Optional[str] = mime_type self.file_name: Optional[str] = file_name self.cover: Optional[Sequence[PhotoSize]] = parse_sequence_arg(cover) - self.start_timestamp: Optional[int] = start_timestamp + self._start_timestamp: Optional[dtm.timedelta] = parse_period_arg(start_timestamp) @property def duration(self) -> Union[int, dtm.timedelta]: @@ -146,12 +152,22 @@ def duration(self) -> Union[int, dtm.timedelta]: value = int(value) return value # type: ignore[return-value] + @property + def start_timestamp(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._start_timestamp) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Video": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None + data["start_timestamp"] = ( + dtm.timedelta(seconds=s) if (s := data.get("start_timestamp")) else None + ) data["cover"] = de_list_optional(data.get("cover"), PhotoSize, bot) return super().de_json(data=data, bot=bot) @@ -159,9 +175,11 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Video": def to_dict(self, recursive: bool = True) -> JSONDict: """See :meth:`telegram.TelegramObject.to_dict`.""" out = super().to_dict(recursive) - if self._duration is not None: - seconds = self._duration.total_seconds() - out["duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["duration"] = self._duration + keys = ("duration", "start_timestamp") + for key in keys: + if (value := getattr(self, "_" + key)) is not None: + seconds = value.total_seconds() + out[key] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out[key] = value return out diff --git a/telegram/_inline/inputlocationmessagecontent.py b/telegram/_inline/inputlocationmessagecontent.py index f71a716c259..ede72bad83b 100644 --- a/telegram/_inline/inputlocationmessagecontent.py +++ b/telegram/_inline/inputlocationmessagecontent.py @@ -18,11 +18,14 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InputLocationMessageContent.""" -from typing import Final, Optional +import datetime as dtm +from typing import Final, Optional, Union from telegram import constants from telegram._inline.inputmessagecontent import InputMessageContent -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class InputLocationMessageContent(InputMessageContent): @@ -39,12 +42,15 @@ class InputLocationMessageContent(InputMessageContent): horizontal_accuracy (:obj:`float`, optional): The radius of uncertainty for the location, measured in meters; 0- :tg-const:`telegram.InputLocationMessageContent.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`, optional): Period in seconds for which the location will be - updated, should be between + live_period (:obj:`int` | :class:`datetime.timedelta`, optional): Period in seconds for + which the location will be updated, should be between :tg-const:`telegram.InputLocationMessageContent.MIN_LIVE_PERIOD` and :tg-const:`telegram.InputLocationMessageContent.MAX_LIVE_PERIOD` or :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` for live locations that can be edited indefinitely. + + .. versionchanged:: NEXT.VERSION + |time-period-input| heading (:obj:`int`, optional): For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InputLocationMessageContent.MIN_HEADING` and @@ -61,10 +67,13 @@ class InputLocationMessageContent(InputMessageContent): horizontal_accuracy (:obj:`float`): Optional. The radius of uncertainty for the location, measured in meters; 0- :tg-const:`telegram.InputLocationMessageContent.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`): Optional. Period in seconds for which the location can be - updated, should be between + live_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Period in seconds for + which the location can be updated, should be between :tg-const:`telegram.InputLocationMessageContent.MIN_LIVE_PERIOD` and :tg-const:`telegram.InputLocationMessageContent.MAX_LIVE_PERIOD`. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| heading (:obj:`int`): Optional. For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InputLocationMessageContent.MIN_HEADING` and @@ -78,19 +87,20 @@ class InputLocationMessageContent(InputMessageContent): """ __slots__ = ( + "_live_period", "heading", "horizontal_accuracy", "latitude", - "live_period", "longitude", - "proximity_alert_radius") + "proximity_alert_radius", + ) # fmt: on def __init__( self, latitude: float, longitude: float, - live_period: Optional[int] = None, + live_period: Optional[TimePeriod] = None, horizontal_accuracy: Optional[float] = None, heading: Optional[int] = None, proximity_alert_radius: Optional[int] = None, @@ -104,7 +114,7 @@ def __init__( self.longitude: float = longitude # Optionals - self.live_period: Optional[int] = live_period + self._live_period: Optional[dtm.timedelta] = parse_period_arg(live_period) self.horizontal_accuracy: Optional[float] = horizontal_accuracy self.heading: Optional[int] = heading self.proximity_alert_radius: Optional[int] = ( @@ -113,6 +123,23 @@ def __init__( self._id_attrs = (self.latitude, self.longitude) + @property + def live_period(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._live_period) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._live_period is not None: + seconds = self._live_period.total_seconds() + out["live_period"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["live_period"] = self._live_period + return out + HORIZONTAL_ACCURACY: Final[int] = constants.LocationLimit.HORIZONTAL_ACCURACY """:const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY` diff --git a/telegram/_messageautodeletetimerchanged.py b/telegram/_messageautodeletetimerchanged.py index 1653c050d59..657fdf268cb 100644 --- a/telegram/_messageautodeletetimerchanged.py +++ b/telegram/_messageautodeletetimerchanged.py @@ -20,10 +20,16 @@ deletion. """ -from typing import Optional +import datetime as dtm +from typing import TYPE_CHECKING, Optional, Union from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod + +if TYPE_CHECKING: + from telegram import Bot class MessageAutoDeleteTimerChanged(TelegramObject): @@ -35,26 +41,63 @@ class MessageAutoDeleteTimerChanged(TelegramObject): .. versionadded:: 13.4 Args: - message_auto_delete_time (:obj:`int`): New auto-delete time for messages in the - chat. + message_auto_delete_time (:obj:`int` | :class:`datetime.timedelta`): New auto-delete time + for messages in the chat. + + .. versionchanged:: NEXT.VERSION + |time-period-input| Attributes: - message_auto_delete_time (:obj:`int`): New auto-delete time for messages in the - chat. + message_auto_delete_time (:obj:`int` | :class:`datetime.timedelta`): New auto-delete time + for messages in the chat. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| """ - __slots__ = ("message_auto_delete_time",) + __slots__ = ("_message_auto_delete_time",) def __init__( self, - message_auto_delete_time: int, + message_auto_delete_time: TimePeriod, *, api_kwargs: Optional[JSONDict] = None, ): super().__init__(api_kwargs=api_kwargs) - self.message_auto_delete_time: int = message_auto_delete_time + self._message_auto_delete_time: dtm.timedelta = parse_period_arg( + message_auto_delete_time + ) # type: ignore[assignment] self._id_attrs = (self.message_auto_delete_time,) self._freeze() + + @property + def message_auto_delete_time(self) -> Union[int, dtm.timedelta]: + value = get_timedelta_value(self._message_auto_delete_time) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + + @classmethod + def de_json( + cls, data: JSONDict, bot: Optional["Bot"] = None + ) -> "MessageAutoDeleteTimerChanged": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + data["message_auto_delete_time"] = ( + dtm.timedelta(seconds=s) if (s := data.get("message_auto_delete_time")) else None + ) + + return super().de_json(data=data, bot=bot) + + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._message_auto_delete_time is not None: + seconds = self._message_auto_delete_time.total_seconds() + out["message_auto_delete_time"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["message_auto_delete_time"] = self._message_auto_delete_time + return out diff --git a/telegram/_poll.py b/telegram/_poll.py index 8ecdc4105f9..fc722884e32 100644 --- a/telegram/_poll.py +++ b/telegram/_poll.py @@ -19,7 +19,7 @@ """This module contains an object that represents a Telegram Poll.""" import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Final, Optional +from typing import TYPE_CHECKING, Final, Optional, Union from telegram import constants from telegram._chat import Chat @@ -27,11 +27,20 @@ from telegram._telegramobject import TelegramObject from telegram._user import User from telegram._utils import enum -from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg -from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_period_arg, + parse_sequence_arg, +) +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_timedelta_value, +) from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.entities import parse_message_entities, parse_message_entity -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod if TYPE_CHECKING: from telegram import Bot @@ -343,8 +352,11 @@ class Poll(TelegramObject): * This attribute is now always a (possibly empty) list and never :obj:`None`. * |sequenceclassargs| - open_period (:obj:`int`, optional): Amount of time in seconds the poll will be active - after creation. + open_period (:obj:`int` | :class:`datetime.timedelta`, optional): Amount of time in seconds + the poll will be active after creation. + + .. versionchanged:: NEXT.VERSION + |time-period-input| close_date (:obj:`datetime.datetime`, optional): Point in time (Unix timestamp) when the poll will be automatically closed. Converted to :obj:`datetime.datetime`. @@ -384,8 +396,11 @@ class Poll(TelegramObject): .. versionchanged:: 20.0 This attribute is now always a (possibly empty) list and never :obj:`None`. - open_period (:obj:`int`): Optional. Amount of time in seconds the poll will be active - after creation. + open_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Amount of time in seconds + the poll will be active after creation. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| close_date (:obj:`datetime.datetime`): Optional. Point in time when the poll will be automatically closed. @@ -401,6 +416,7 @@ class Poll(TelegramObject): """ __slots__ = ( + "_open_period", "allows_multiple_answers", "close_date", "correct_option_id", @@ -409,7 +425,6 @@ class Poll(TelegramObject): "id", "is_anonymous", "is_closed", - "open_period", "options", "question", "question_entities", @@ -430,7 +445,7 @@ def __init__( correct_option_id: Optional[int] = None, explanation: Optional[str] = None, explanation_entities: Optional[Sequence[MessageEntity]] = None, - open_period: Optional[int] = None, + open_period: Optional[TimePeriod] = None, close_date: Optional[dtm.datetime] = None, question_entities: Optional[Sequence[MessageEntity]] = None, *, @@ -450,7 +465,7 @@ def __init__( self.explanation_entities: tuple[MessageEntity, ...] = parse_sequence_arg( explanation_entities ) - self.open_period: Optional[int] = open_period + self._open_period: Optional[dtm.timedelta] = parse_period_arg(open_period) self.close_date: Optional[dtm.datetime] = close_date self.question_entities: tuple[MessageEntity, ...] = parse_sequence_arg(question_entities) @@ -458,6 +473,13 @@ def __init__( self._freeze() + @property + def open_period(self) -> Optional[Union[int, dtm.timedelta]]: + value = get_timedelta_value(self._open_period) + if isinstance(value, float) and value.is_integer(): + value = int(value) + return value # type: ignore[return-value] + @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Poll": """See :meth:`telegram.TelegramObject.de_json`.""" @@ -474,9 +496,20 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Poll": data["question_entities"] = de_list_optional( data.get("question_entities"), MessageEntity, bot ) + data["open_period"] = dtm.timedelta(seconds=s) if (s := data.get("open_period")) else None return super().de_json(data=data, bot=bot) + def to_dict(self, recursive: bool = True) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + out = super().to_dict(recursive) + if self._open_period is not None: + seconds = self._open_period.total_seconds() + out["open_period"] = int(seconds) if seconds.is_integer() else seconds + elif not recursive: + out["open_period"] = self._open_period + return out + def parse_explanation_entity(self, entity: MessageEntity) -> str: """Returns the text in :attr:`explanation` from a given :class:`telegram.MessageEntity` of :attr:`explanation_entities`. diff --git a/tests/_files/test_location.py b/tests/_files/test_location.py index 5ccddbac527..d96fd11297d 100644 --- a/tests/_files/test_location.py +++ b/tests/_files/test_location.py @@ -25,6 +25,7 @@ from telegram.constants import ParseMode from telegram.error import BadRequest from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.build_messages import make_message from tests.auxil.slots import mro_slots @@ -45,7 +46,7 @@ class LocationTestBase: latitude = -23.691288 longitude = -46.788279 horizontal_accuracy = 999 - live_period = 60 + live_period = dtm.timedelta(seconds=60) heading = 90 proximity_alert_radius = 50 @@ -61,7 +62,7 @@ def test_de_json(self, offline_bot): "latitude": self.latitude, "longitude": self.longitude, "horizontal_accuracy": self.horizontal_accuracy, - "live_period": self.live_period, + "live_period": int(self.live_period.total_seconds()), "heading": self.heading, "proximity_alert_radius": self.proximity_alert_radius, } @@ -71,7 +72,7 @@ def test_de_json(self, offline_bot): assert location.latitude == self.latitude assert location.longitude == self.longitude assert location.horizontal_accuracy == self.horizontal_accuracy - assert location.live_period == self.live_period + assert location._live_period == self.live_period assert location.heading == self.heading assert location.proximity_alert_radius == self.proximity_alert_radius @@ -81,10 +82,29 @@ def test_to_dict(self, location): assert location_dict["latitude"] == location.latitude assert location_dict["longitude"] == location.longitude assert location_dict["horizontal_accuracy"] == location.horizontal_accuracy - assert location_dict["live_period"] == location.live_period + assert location_dict["live_period"] == int(self.live_period.total_seconds()) + assert isinstance(location_dict["live_period"], int) assert location["heading"] == location.heading assert location["proximity_alert_radius"] == location.proximity_alert_radius + def test_time_period_properties(self, PTB_TIMEDELTA, location): + if PTB_TIMEDELTA: + assert location.live_period == self.live_period + assert isinstance(location.live_period, dtm.timedelta) + else: + assert location.live_period == int(self.live_period.total_seconds()) + assert isinstance(location.live_period, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, location): + location.live_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = Location(self.longitude, self.latitude) b = Location(self.longitude, self.latitude) diff --git a/tests/_files/test_video.py b/tests/_files/test_video.py index b976386ea37..20c7404b615 100644 --- a/tests/_files/test_video.py +++ b/tests/_files/test_video.py @@ -39,6 +39,13 @@ from tests.auxil.slots import mro_slots +@pytest.fixture(scope="module") +def video(video): + with video._unfrozen(): + video._start_timestamp = VideoTestBase.start_timestamp + return video + + class VideoTestBase: width = 360 height = 640 @@ -47,7 +54,7 @@ class VideoTestBase: mime_type = "video/mp4" supports_streaming = True file_name = "telegram.mp4" - start_timestamp = 3 + start_timestamp = dtm.timedelta(seconds=3) cover = (PhotoSize("file_id", "unique_id", 640, 360, file_size=0),) thumb_width = 180 thumb_height = 320 @@ -84,6 +91,7 @@ def test_expected_values(self, video): assert video._duration == self.duration assert video.file_size == self.file_size assert video.mime_type == self.mime_type + assert video._start_timestamp == self.start_timestamp def test_de_json(self, offline_bot): json_dict = { @@ -95,7 +103,7 @@ def test_de_json(self, offline_bot): "mime_type": self.mime_type, "file_size": self.file_size, "file_name": self.file_name, - "start_timestamp": self.start_timestamp, + "start_timestamp": int(self.start_timestamp.total_seconds()), "cover": [photo_size.to_dict() for photo_size in self.cover], } json_video = Video.de_json(json_dict, offline_bot) @@ -109,7 +117,7 @@ def test_de_json(self, offline_bot): assert json_video.mime_type == self.mime_type assert json_video.file_size == self.file_size assert json_video.file_name == self.file_name - assert json_video.start_timestamp == self.start_timestamp + assert json_video._start_timestamp == self.start_timestamp assert json_video.cover == self.cover def test_to_dict(self, video): @@ -125,24 +133,34 @@ def test_to_dict(self, video): assert video_dict["mime_type"] == video.mime_type assert video_dict["file_size"] == video.file_size assert video_dict["file_name"] == video.file_name + assert video_dict["start_timestamp"] == int(self.start_timestamp.total_seconds()) + assert isinstance(video_dict["start_timestamp"], int) def test_time_period_properties(self, PTB_TIMEDELTA, video): if PTB_TIMEDELTA: assert video.duration == self.duration assert isinstance(video.duration, dtm.timedelta) + + assert video.start_timestamp == self.start_timestamp + assert isinstance(video.start_timestamp, dtm.timedelta) else: assert video.duration == int(self.duration.total_seconds()) assert isinstance(video.duration, int) + assert video.start_timestamp == int(self.start_timestamp.total_seconds()) + assert isinstance(video.start_timestamp, int) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, video): video.duration + video.start_timestamp if PTB_TIMEDELTA: assert len(recwarn) == 0 else: - assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) - assert recwarn[0].category is PTBDeprecationWarning + assert len(recwarn) == 2 + for i in range(2): + assert "will be of type `datetime.timedelta`" in str(recwarn[i].message) + assert recwarn[i].category is PTBDeprecationWarning def test_equality(self, video): a = Video(video.file_id, video.file_unique_id, self.width, self.height, self.duration) @@ -286,7 +304,7 @@ async def test_send_all_args( assert message.video.thumbnail.width == self.thumb_width assert message.video.thumbnail.height == self.thumb_height - assert message.video.start_timestamp == self.start_timestamp + assert message.video._start_timestamp == self.start_timestamp assert isinstance(message.video.cover, tuple) assert isinstance(message.video.cover[0], PhotoSize) diff --git a/tests/_inline/test_inputlocationmessagecontent.py b/tests/_inline/test_inputlocationmessagecontent.py index 05e86086852..c57e1c157f6 100644 --- a/tests/_inline/test_inputlocationmessagecontent.py +++ b/tests/_inline/test_inputlocationmessagecontent.py @@ -16,9 +16,12 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import InputLocationMessageContent, Location +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -37,7 +40,7 @@ def input_location_message_content(): class InputLocationMessageContentTestBase: latitude = -23.691288 longitude = -46.788279 - live_period = 80 + live_period = dtm.timedelta(seconds=80) horizontal_accuracy = 50.5 heading = 90 proximity_alert_radius = 999 @@ -53,7 +56,7 @@ def test_slot_behaviour(self, input_location_message_content): def test_expected_values(self, input_location_message_content): assert input_location_message_content.longitude == self.longitude assert input_location_message_content.latitude == self.latitude - assert input_location_message_content.live_period == self.live_period + assert input_location_message_content._live_period == self.live_period assert input_location_message_content.horizontal_accuracy == self.horizontal_accuracy assert input_location_message_content.heading == self.heading assert input_location_message_content.proximity_alert_radius == self.proximity_alert_radius @@ -70,10 +73,10 @@ def test_to_dict(self, input_location_message_content): input_location_message_content_dict["longitude"] == input_location_message_content.longitude ) - assert ( - input_location_message_content_dict["live_period"] - == input_location_message_content.live_period + assert input_location_message_content_dict["live_period"] == int( + self.live_period.total_seconds() ) + assert isinstance(input_location_message_content_dict["live_period"], int) assert ( input_location_message_content_dict["horizontal_accuracy"] == input_location_message_content.horizontal_accuracy @@ -87,6 +90,28 @@ def test_to_dict(self, input_location_message_content): == input_location_message_content.proximity_alert_radius ) + def test_time_period_properties(self, PTB_TIMEDELTA, input_location_message_content): + live_period = input_location_message_content.live_period + + if PTB_TIMEDELTA: + assert live_period == self.live_period + assert isinstance(live_period, dtm.timedelta) + else: + assert live_period == int(self.live_period.total_seconds()) + assert isinstance(live_period, int) + + def test_time_period_int_deprecated( + self, recwarn, PTB_TIMEDELTA, input_location_message_content + ): + input_location_message_content.live_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InputLocationMessageContent(123, 456, 70) b = InputLocationMessageContent(123, 456, 90) diff --git a/tests/test_chatinvitelink.py b/tests/test_chatinvitelink.py index 55cfc5763a9..b8627af8adc 100644 --- a/tests/test_chatinvitelink.py +++ b/tests/test_chatinvitelink.py @@ -22,6 +22,7 @@ from telegram import ChatInviteLink, User from telegram._utils.datetime import UTC, to_timestamp +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -56,7 +57,7 @@ class ChatInviteLinkTestBase: member_limit = 42 name = "LinkName" pending_join_request_count = 42 - subscription_period = 43 + subscription_period = dtm.timedelta(seconds=43) subscription_price = 44 @@ -95,7 +96,7 @@ def test_de_json_all_args(self, offline_bot, creator): "member_limit": self.member_limit, "name": self.name, "pending_join_request_count": str(self.pending_join_request_count), - "subscription_period": self.subscription_period, + "subscription_period": int(self.subscription_period.total_seconds()), "subscription_price": self.subscription_price, } @@ -112,7 +113,7 @@ def test_de_json_all_args(self, offline_bot, creator): assert invite_link.member_limit == self.member_limit assert invite_link.name == self.name assert invite_link.pending_join_request_count == self.pending_join_request_count - assert invite_link.subscription_period == self.subscription_period + assert invite_link._subscription_period == self.subscription_period assert invite_link.subscription_price == self.subscription_price def test_de_json_localization(self, tz_bot, offline_bot, raw_bot, creator): @@ -154,9 +155,30 @@ def test_to_dict(self, invite_link): assert invite_link_dict["member_limit"] == self.member_limit assert invite_link_dict["name"] == self.name assert invite_link_dict["pending_join_request_count"] == self.pending_join_request_count - assert invite_link_dict["subscription_period"] == self.subscription_period + assert invite_link_dict["subscription_period"] == int( + self.subscription_period.total_seconds() + ) + assert isinstance(invite_link_dict["subscription_period"], int) assert invite_link_dict["subscription_price"] == self.subscription_price + def test_time_period_properties(self, PTB_TIMEDELTA, invite_link): + if PTB_TIMEDELTA: + assert invite_link.subscription_period == self.subscription_period + assert isinstance(invite_link.subscription_period, dtm.timedelta) + else: + assert invite_link.subscription_period == int(self.subscription_period.total_seconds()) + assert isinstance(invite_link.subscription_period, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, invite_link): + invite_link.subscription_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = ChatInviteLink("link", User(1, "", False), True, True, True) b = ChatInviteLink("link", User(1, "", False), True, True, True) diff --git a/tests/test_messageautodeletetimerchanged.py b/tests/test_messageautodeletetimerchanged.py index 19133e9aaa9..19d2e8b99c5 100644 --- a/tests/test_messageautodeletetimerchanged.py +++ b/tests/test_messageautodeletetimerchanged.py @@ -16,12 +16,15 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + from telegram import MessageAutoDeleteTimerChanged, VideoChatEnded +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots class TestMessageAutoDeleteTimerChangedWithoutRequest: - message_auto_delete_time = 100 + message_auto_delete_time = dtm.timedelta(seconds=100) def test_slot_behaviour(self): action = MessageAutoDeleteTimerChanged(self.message_auto_delete_time) @@ -30,18 +33,45 @@ def test_slot_behaviour(self): assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" def test_de_json(self): - json_dict = {"message_auto_delete_time": self.message_auto_delete_time} + json_dict = { + "message_auto_delete_time": int(self.message_auto_delete_time.total_seconds()) + } madtc = MessageAutoDeleteTimerChanged.de_json(json_dict, None) assert madtc.api_kwargs == {} - assert madtc.message_auto_delete_time == self.message_auto_delete_time + assert madtc._message_auto_delete_time == self.message_auto_delete_time def test_to_dict(self): madtc = MessageAutoDeleteTimerChanged(self.message_auto_delete_time) madtc_dict = madtc.to_dict() assert isinstance(madtc_dict, dict) - assert madtc_dict["message_auto_delete_time"] == self.message_auto_delete_time + assert madtc_dict["message_auto_delete_time"] == int( + self.message_auto_delete_time.total_seconds() + ) + assert isinstance(madtc_dict["message_auto_delete_time"], int) + + def test_time_period_properties(self, PTB_TIMEDELTA): + message_auto_delete_time = MessageAutoDeleteTimerChanged( + self.message_auto_delete_time + ).message_auto_delete_time + + if PTB_TIMEDELTA: + assert message_auto_delete_time == self.message_auto_delete_time + assert isinstance(message_auto_delete_time, dtm.timedelta) + else: + assert message_auto_delete_time == int(self.message_auto_delete_time.total_seconds()) + assert isinstance(message_auto_delete_time, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA): + MessageAutoDeleteTimerChanged(self.message_auto_delete_time).message_auto_delete_time + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): a = MessageAutoDeleteTimerChanged(100) diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index 796f412f2e5..5be01158f39 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -117,10 +117,21 @@ class ParamTypeCheckingExceptions: "slow_mode_delay": int, # actual: Union[int, dtm.timedelta] "message_auto_delete_time": int, # actual: Union[int, dtm.timedelta] }, - "Animation|Audio|Voice|Video(Note|ChatEnded)?|PaidMediaPreview" + "Animation|Audio|Voice|Video(Note|ChatEnded)|PaidMediaPreview" "|Input(Paid)?Media(Audio|Video|Animation)": { "duration": int, # actual: Union[int, dtm.timedelta] }, + "Video": { + "duration": int, # actual: Union[int, dtm.timedelta] + "start_timestamp": int, # actual: Union[int, dtm.timedelta] + }, + "Poll": {"open_period": int}, # actual: Union[int, dtm.timedelta] + "Location": {"live_period": int}, # actual: Union[int, dtm.timedelta] + "ChatInviteLink": {"subscription_period": int}, # actual: Union[int, dtm.timedelta] + "InputLocationMessageContent": {"live_period": int}, # actual: Union[int, dtm.timedelta] + "MessageAutoDeleteTimerChanged": { + "message_auto_delete_time": int + }, # actual: Union[int, dtm.timedelta] "InlineQueryResult.*": { "live_period": int, # actual: Union[int, dtm.timedelta] "voice_duration": int, # actual: Union[int, dtm.timedelta] diff --git a/tests/test_poll.py b/tests/test_poll.py index c7e3da447f5..7a8203aa268 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -22,6 +22,7 @@ from telegram import Chat, InputPollOption, MessageEntity, Poll, PollAnswer, PollOption, User from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import PollType +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -295,7 +296,7 @@ class PollTestBase: b"\\u200d\\U0001f467\\U0001f431http://google.com" ).decode("unicode-escape") explanation_entities = [MessageEntity(13, 17, MessageEntity.URL)] - open_period = 42 + open_period = dtm.timedelta(seconds=42) close_date = dtm.datetime.now(dtm.timezone.utc) question_entities = [ MessageEntity(MessageEntity.BOLD, 0, 4), @@ -316,7 +317,7 @@ def test_de_json(self, offline_bot): "allows_multiple_answers": self.allows_multiple_answers, "explanation": self.explanation, "explanation_entities": [self.explanation_entities[0].to_dict()], - "open_period": self.open_period, + "open_period": int(self.open_period.total_seconds()), "close_date": to_timestamp(self.close_date), "question_entities": [e.to_dict() for e in self.question_entities], } @@ -337,7 +338,7 @@ def test_de_json(self, offline_bot): assert poll.allows_multiple_answers == self.allows_multiple_answers assert poll.explanation == self.explanation assert poll.explanation_entities == tuple(self.explanation_entities) - assert poll.open_period == self.open_period + assert poll._open_period == self.open_period assert abs(poll.close_date - self.close_date) < dtm.timedelta(seconds=1) assert to_timestamp(poll.close_date) == to_timestamp(self.close_date) assert poll.question_entities == tuple(self.question_entities) @@ -354,7 +355,7 @@ def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): "allows_multiple_answers": self.allows_multiple_answers, "explanation": self.explanation, "explanation_entities": [self.explanation_entities[0].to_dict()], - "open_period": self.open_period, + "open_period": int(self.open_period.total_seconds()), "close_date": to_timestamp(self.close_date), "question_entities": [e.to_dict() for e in self.question_entities], } @@ -387,10 +388,28 @@ def test_to_dict(self, poll): assert poll_dict["allows_multiple_answers"] == poll.allows_multiple_answers assert poll_dict["explanation"] == poll.explanation assert poll_dict["explanation_entities"] == [poll.explanation_entities[0].to_dict()] - assert poll_dict["open_period"] == poll.open_period + assert poll_dict["open_period"] == int(self.open_period.total_seconds()) assert poll_dict["close_date"] == to_timestamp(poll.close_date) assert poll_dict["question_entities"] == [e.to_dict() for e in poll.question_entities] + def test_time_period_properties(self, PTB_TIMEDELTA, poll): + if PTB_TIMEDELTA: + assert poll.open_period == self.open_period + assert isinstance(poll.open_period, dtm.timedelta) + else: + assert poll.open_period == int(self.open_period.total_seconds()) + assert isinstance(poll.open_period, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, poll): + poll.open_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) b = Poll(123, "question", ["o1", "o2"], 1, True, False, Poll.REGULAR, True) From caa831b859fa1e75802bd32b2093464241de0bbe Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sat, 17 May 2025 21:40:48 +0300 Subject: [PATCH 12/30] Modify test_official to handle time periods as timedelta automatically. This also discovered `Bot.get_updates.timeout` thas was missed (?) in #4651. --- tests/test_official/arg_type_checker.py | 12 ++------ tests/test_official/exceptions.py | 38 ++----------------------- 2 files changed, 5 insertions(+), 45 deletions(-) diff --git a/tests/test_official/arg_type_checker.py b/tests/test_official/arg_type_checker.py index 4b0e3630691..f5e7cb414e5 100644 --- a/tests/test_official/arg_type_checker.py +++ b/tests/test_official/arg_type_checker.py @@ -68,7 +68,7 @@ """, re.VERBOSE, ) -TIMEDELTA_REGEX = re.compile(r"\w+_period$") # Parameter names ending with "_period" +TIMEDELTA_REGEX = re.compile(r"(in|number of) seconds") log = logging.debug @@ -194,15 +194,9 @@ def check_param_type( mapped_type = dtm.datetime if is_class else mapped_type | dtm.datetime # 4) HANDLING TIMEDELTA: - elif re.search(TIMEDELTA_REGEX, ptb_param.name) and obj.__name__ in ( - "TransactionPartnerUser", - "create_invoice_link", - ): - # Currently we only support timedelta for `subscription_period` in `TransactionPartnerUser` - # and `create_invoice_link`. - # See https://github.com/python-telegram-bot/python-telegram-bot/issues/4575 + elif re.search(TIMEDELTA_REGEX, tg_parameter.param_description): log("Checking that `%s` is a timedelta!\n", ptb_param.name) - mapped_type = dtm.timedelta if is_class else mapped_type | dtm.timedelta + mapped_type = mapped_type | dtm.timedelta # 5) COMPLEX TYPES: # Some types are too complicated, so we replace our annotation with a simpler type: diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index 5be01158f39..d86819d649b 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains exceptions to our API compared to the official API.""" -import datetime as dtm from telegram import Animation, Audio, Document, Gift, PhotoSize, Sticker, Video, VideoNote, Voice from tests.test_official.helpers import _get_params_base @@ -55,12 +54,6 @@ class ParamTypeCheckingExceptions: "replace_sticker_in_set": { "old_sticker$": Sticker, }, - # The underscore will match any method - r"\w+_[\w_]+": { - "duration": dtm.timedelta, - r"\w+_period": dtm.timedelta, - "cache_time": dtm.timedelta, - }, } # TODO: Look into merging this with COMPLEX_TYPES @@ -102,7 +95,6 @@ class ParamTypeCheckingExceptions: }, "InputProfilePhotoAnimated": { "animation": str, # actual: Union[str, FileInput] - "main_frame_timestamp": float, # actual: Union[float, dtm.timedelta] }, "InputSticker": { "sticker": str, # actual: Union[str, FileInput] @@ -110,35 +102,9 @@ class ParamTypeCheckingExceptions: "InputStoryContent.*": { "photo": str, # actual: Union[str, FileInput] "video": str, # actual: Union[str, FileInput] - "duration": float, # actual: dtm.timedelta - "cover_frame_timestamp": float, # actual: dtm.timedelta - }, - "ChatFullInfo": { - "slow_mode_delay": int, # actual: Union[int, dtm.timedelta] - "message_auto_delete_time": int, # actual: Union[int, dtm.timedelta] - }, - "Animation|Audio|Voice|Video(Note|ChatEnded)|PaidMediaPreview" - "|Input(Paid)?Media(Audio|Video|Animation)": { - "duration": int, # actual: Union[int, dtm.timedelta] - }, - "Video": { - "duration": int, # actual: Union[int, dtm.timedelta] - "start_timestamp": int, # actual: Union[int, dtm.timedelta] }, - "Poll": {"open_period": int}, # actual: Union[int, dtm.timedelta] - "Location": {"live_period": int}, # actual: Union[int, dtm.timedelta] - "ChatInviteLink": {"subscription_period": int}, # actual: Union[int, dtm.timedelta] - "InputLocationMessageContent": {"live_period": int}, # actual: Union[int, dtm.timedelta] - "MessageAutoDeleteTimerChanged": { - "message_auto_delete_time": int - }, # actual: Union[int, dtm.timedelta] - "InlineQueryResult.*": { - "live_period": int, # actual: Union[int, dtm.timedelta] - "voice_duration": int, # actual: Union[int, dtm.timedelta] - "audio_duration": int, # actual: Union[int, dtm.timedelta] - "video_duration": int, # actual: Union[int, dtm.timedelta] - "mpeg4_duration": int, # actual: Union[int, dtm.timedelta] - "gif_duration": int, # actual: Union[int, dtm.timedelta] + "TransactionPartnerUser": { + "subscription_period": int, # actual: Union[int, dtm.timedelta] }, "EncryptedPassportElement": { "data": str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress] From 881a9f65048d8f3d93764300ea17a5f19685e9f2 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sat, 17 May 2025 22:54:51 +0300 Subject: [PATCH 13/30] Accept timedeltas in Bot.get_updates.timeout. --- examples/rawapibot.py | 5 ++++- telegram/_bot.py | 19 ++++++++++++++----- telegram/ext/_extbot.py | 2 +- tests/ext/test_applicationbuilder.py | 4 ++++ tests/test_bot.py | 8 ++++++-- 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/examples/rawapibot.py b/examples/rawapibot.py index b6a70fc3de0..34ac964c4b7 100644 --- a/examples/rawapibot.py +++ b/examples/rawapibot.py @@ -7,6 +7,7 @@ """ import asyncio import contextlib +import datetime as dtm import logging from typing import NoReturn @@ -47,7 +48,9 @@ async def main() -> NoReturn: async def echo(bot: Bot, update_id: int) -> int: """Echo the message the user sent.""" # Request updates after the last update_id - updates = await bot.get_updates(offset=update_id, timeout=10, allowed_updates=Update.ALL_TYPES) + updates = await bot.get_updates( + offset=update_id, timeout=dtm.timedelta(seconds=10), allowed_updates=Update.ALL_TYPES + ) for update in updates: next_update_id = update.update_id + 1 diff --git a/telegram/_bot.py b/telegram/_bot.py index 90f6cf0bf42..a72fc10b796 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -4519,7 +4519,7 @@ async def get_updates( self, offset: Optional[int] = None, limit: Optional[int] = None, - timeout: Optional[int] = None, + timeout: Optional[TimePeriod] = None, allowed_updates: Optional[Sequence[str]] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -4554,9 +4554,12 @@ async def get_updates( between :tg-const:`telegram.constants.PollingLimit.MIN_LIMIT`- :tg-const:`telegram.constants.PollingLimit.MAX_LIMIT` are accepted. Defaults to ``100``. - timeout (:obj:`int`, optional): Timeout in seconds for long polling. Defaults to ``0``, - i.e. usual short polling. Should be positive, short polling should be used for - testing purposes only. + timeout (:obj:`int` | :class:`datetime.timedelta`, optional): Timeout in seconds for + long polling. Defaults to ``0``, i.e. usual short polling. Should be positive, + short polling should be used for testing purposes only. + + .. versionchanged:: NEXT.VERSION + |time-period-input| allowed_updates (Sequence[:obj:`str`]), optional): A sequence the types of updates you want your bot to receive. For example, specify ["message", "edited_channel_post", "callback_query"] to only receive updates of these types. @@ -4591,6 +4594,12 @@ async def get_updates( else: arg_read_timeout = self._request[0].read_timeout or 0 + read_timeout = ( + (arg_read_timeout + timeout.total_seconds()) + if isinstance(timeout, dtm.timedelta) + else (arg_read_timeout + timeout if isinstance(timeout, int) else arg_read_timeout) + ) + # Ideally we'd use an aggressive read timeout for the polling. However, # * Short polling should return within 2 seconds. # * Long polling poses a different problem: the connection might have been dropped while @@ -4601,7 +4610,7 @@ async def get_updates( await self._post( "getUpdates", data, - read_timeout=arg_read_timeout + timeout if timeout else arg_read_timeout, + read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index 7afadaa89fa..5781cf817bc 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -657,7 +657,7 @@ async def get_updates( self, offset: Optional[int] = None, limit: Optional[int] = None, - timeout: Optional[int] = None, + timeout: Optional[TimePeriod] = None, allowed_updates: Optional[Sequence[str]] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, diff --git a/tests/ext/test_applicationbuilder.py b/tests/ext/test_applicationbuilder.py index 15e85b6416e..bfbce15dd93 100644 --- a/tests/ext/test_applicationbuilder.py +++ b/tests/ext/test_applicationbuilder.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio +import datetime as dtm import inspect from dataclasses import dataclass from http import HTTPStatus @@ -576,9 +577,12 @@ def test_no_job_queue(self, bot, builder): (None, None, 0), (1, None, 1), (None, 1, 1), + (None, dtm.timedelta(seconds=1), 1), (DEFAULT_NONE, None, 10), (DEFAULT_NONE, 1, 11), + (DEFAULT_NONE, dtm.timedelta(seconds=1), 11), (1, 2, 3), + (1, dtm.timedelta(seconds=2), 3), ], ) async def test_get_updates_read_timeout_value_passing( diff --git a/tests/test_bot.py b/tests/test_bot.py index 16c878dd29c..4e78cd0a449 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -3266,9 +3266,10 @@ async def test_edit_reply_markup_inline(self): pass # TODO: Actually send updates to the test bot so this can be tested properly - async def test_get_updates(self, bot): + @pytest.mark.parametrize("timeout", [1, dtm.timedelta(seconds=1)]) + async def test_get_updates(self, bot, timeout): await bot.delete_webhook() # make sure there is no webhook set if webhook tests failed - updates = await bot.get_updates(timeout=1) + updates = await bot.get_updates(timeout=timeout) assert isinstance(updates, tuple) if updates: @@ -3280,9 +3281,12 @@ async def test_get_updates(self, bot): (None, None, 0), (1, None, 1), (None, 1, 1), + (None, dtm.timedelta(seconds=1), 1), (DEFAULT_NONE, None, 10), (DEFAULT_NONE, 1, 11), + (DEFAULT_NONE, dtm.timedelta(seconds=1), 11), (1, 2, 3), + (1, dtm.timedelta(seconds=2), 3), ], ) async def test_get_updates_read_timeout_value_passing( From 466e0b07a0c3cad5893662cfd4b08a822adaaa81 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sun, 18 May 2025 02:33:12 +0300 Subject: [PATCH 14/30] Elaborate chango fragment for PR. --- .../4750.jJBu7iAgZa96hdqcpHK96W.toml | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml b/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml index c3667bda717..c116d315691 100644 --- a/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml +++ b/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml @@ -1,5 +1,35 @@ -other = "Use `timedelta` to represent time periods in classes" +features = "Use `timedelta` to represent time periods in classes" +deprecations = """In this release, we're migrating attributes that represent durations/time periods from :ob:`int` type to Python's native :class:`datetime.timedelta`. + +Enable the ``PTB_TIMEDELTA`` environment variable to adopt :obj:`timedelta` now. Support for :obj:`int` values is deprecated and will be removed in a future major version. + +Affected Attributes: +- :attr:`telegram.ChatFullInfo.slow_mode_delay` and :attr:`telegram.ChatFullInfo.message_auto_delete_time` +- :attr:`telegram.Animation.duration` +- :attr:`telegram.Audio.duration` +- :attr:`telegram.Video.duration` and :attr:`telegram.Video.start_timestamp` +- :attr:`telegram.VideoNote.duration` +- :attr:`telegram.Voice.duration` +- :attr:`telegram.PaidMediaPreview.duration` +- :attr:`telegram.VideoChatEnded.duration` +- :attr:`telegram.InputMediaVideo.duration` +- :attr:`telegram.InputMediaAnimation.duration` +- :attr:`telegram.InputMediaAudio.duration` +- :attr:`telegram.InputPaidMediaVideo.duration` +- :attr:`telegram.InlineQueryResultGif.gif_duration` +- :attr:`telegram.InlineQueryResultMpeg4Gif.mpeg4_duration` +- :attr:`telegram.InlineQueryResultVideo.video_duration` +- :attr:`telegram.InlineQueryResultAudio.audio_duration` +- :attr:`telegram.InlineQueryResultVoice.voice_duration` +- :attr:`telegram.InlineQueryResultLocation.live_period` +- :attr:`telegram.Poll.open_period` +- :attr:`telegram.Location.live_period` +- :attr:`telegram.MessageAutoDeleteTimerChanged.message_auto_delete_time` +- :attr:`telegram.ChatInviteLink.subscription_period` +- :attr:`telegram.InputLocationMessageContent.live_period` +""" +internal = "Modify test_official to handle time periods as timedelta automatically." [[pull_requests]] uid = "4750" author_uid = "aelkheir" -closes_threads = [] +closes_threads = ["4575"] From fb9a7095834303139afc3b0342da09ae6fe90506 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Tue, 20 May 2025 19:53:03 +0300 Subject: [PATCH 15/30] Update ``timeout`` type annotation in Application, Updater methods. Application.run_polling, Updater.start_polling, Updater._start_polling. --- telegram/ext/_application.py | 12 ++++++++---- telegram/ext/_updater.py | 17 +++++++++++------ tests/ext/test_updater.py | 3 ++- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/telegram/ext/_application.py b/telegram/ext/_application.py index e856fa85321..30a786cf038 100644 --- a/telegram/ext/_application.py +++ b/telegram/ext/_application.py @@ -20,6 +20,7 @@ import asyncio import contextlib +import datetime as dtm import inspect import itertools import platform @@ -42,7 +43,7 @@ ) from telegram._utils.logging import get_logger from telegram._utils.repr import build_repr_with_selected_attrs -from telegram._utils.types import SCT, DVType, ODVInput +from telegram._utils.types import SCT, DVType, ODVInput, TimePeriod from telegram._utils.warnings import warn from telegram.error import TelegramError from telegram.ext._basepersistence import BasePersistence @@ -739,7 +740,7 @@ def stop_running(self) -> None: def run_polling( self, poll_interval: float = 0.0, - timeout: int = 10, + timeout: TimePeriod = dtm.timedelta(seconds=10), bootstrap_retries: int = 0, allowed_updates: Optional[Sequence[str]] = None, drop_pending_updates: Optional[bool] = None, @@ -780,8 +781,11 @@ def run_polling( Args: poll_interval (:obj:`float`, optional): Time to wait between polling updates from Telegram in seconds. Default is ``0.0``. - timeout (:obj:`int`, optional): Passed to - :paramref:`telegram.Bot.get_updates.timeout`. Default is ``10`` seconds. + timeout (:obj:`int` | :class:`datetime.timedelta`, optional): Passed to + :paramref:`telegram.Bot.get_updates.timeout`. Default is ``timedelta(seconds=10)``. + + .. versionchanged:: NEXT.VERSION + |time-period-input| bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase (calling :meth:`initialize` and the boostrapping of :meth:`telegram.ext.Updater.start_polling`) diff --git a/telegram/ext/_updater.py b/telegram/ext/_updater.py index 95f7e225ed1..63634fbc467 100644 --- a/telegram/ext/_updater.py +++ b/telegram/ext/_updater.py @@ -20,6 +20,7 @@ import asyncio import contextlib +import datetime as dtm import ssl from collections.abc import Coroutine, Sequence from pathlib import Path @@ -29,7 +30,7 @@ from telegram._utils.defaultvalue import DEFAULT_80, DEFAULT_IP, DefaultValue from telegram._utils.logging import get_logger from telegram._utils.repr import build_repr_with_selected_attrs -from telegram._utils.types import DVType +from telegram._utils.types import DVType, TimePeriod from telegram.error import TelegramError from telegram.ext._utils.networkloop import network_retry_loop @@ -206,7 +207,7 @@ async def shutdown(self) -> None: async def start_polling( self, poll_interval: float = 0.0, - timeout: int = 10, + timeout: TimePeriod = dtm.timedelta(seconds=10), bootstrap_retries: int = 0, allowed_updates: Optional[Sequence[str]] = None, drop_pending_updates: Optional[bool] = None, @@ -226,8 +227,12 @@ async def start_polling( Args: poll_interval (:obj:`float`, optional): Time to wait between polling updates from Telegram in seconds. Default is ``0.0``. - timeout (:obj:`int`, optional): Passed to - :paramref:`telegram.Bot.get_updates.timeout`. Defaults to ``10`` seconds. + timeout (:obj:`int` | :class:`datetime.timedelta`, optional): Passed to + :paramref:`telegram.Bot.get_updates.timeout`. Defaults to + ``timedelta(seconds=10)``. + + .. versionchanged:: NEXT.VERSION + |time-period-input| bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of will retry on failures on the Telegram server. @@ -309,7 +314,7 @@ def callback(error: telegram.error.TelegramError) async def _start_polling( self, poll_interval: float, - timeout: int, + timeout: TimePeriod, bootstrap_retries: int, drop_pending_updates: Optional[bool], allowed_updates: Optional[Sequence[str]], @@ -394,7 +399,7 @@ async def _get_updates_cleanup() -> None: await self.bot.get_updates( offset=self._last_update_id, # We don't want to do long polling here! - timeout=0, + timeout=dtm.timedelta(seconds=0), allowed_updates=allowed_updates, ) except TelegramError: diff --git a/tests/ext/test_updater.py b/tests/ext/test_updater.py index 147fc6128df..0ed37b6fadd 100644 --- a/tests/ext/test_updater.py +++ b/tests/ext/test_updater.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio +import datetime as dtm import logging import platform from collections import defaultdict @@ -294,7 +295,7 @@ async def test_polling_mark_updates_as_read(self, monkeypatch, updater, caplog): tracking_flag = False received_kwargs = {} expected_kwargs = { - "timeout": 0, + "timeout": dtm.timedelta(seconds=0), "allowed_updates": "allowed_updates", } From 4e9f5fa790a1f9b944e202a460970ee6450abc80 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Fri, 23 May 2025 06:01:44 +0300 Subject: [PATCH 16/30] Accept timedelta in RetryAfter. --- .../4750.jJBu7iAgZa96hdqcpHK96W.toml | 1 + telegram/error.py | 44 +++++++++++++++---- tests/test_error.py | 42 ++++++++++++++---- 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml b/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml index c116d315691..c77500c10bb 100644 --- a/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml +++ b/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml @@ -27,6 +27,7 @@ Affected Attributes: - :attr:`telegram.MessageAutoDeleteTimerChanged.message_auto_delete_time` - :attr:`telegram.ChatInviteLink.subscription_period` - :attr:`telegram.InputLocationMessageContent.live_period` +- :attr:`telegram.error.RetryAfter.retry_after` """ internal = "Modify test_official to handle time periods as timedelta automatically." [[pull_requests]] diff --git a/telegram/error.py b/telegram/error.py index 2de0361762d..140ba778089 100644 --- a/telegram/error.py +++ b/telegram/error.py @@ -22,6 +22,13 @@ Replaced ``Unauthorized`` by :class:`Forbidden`. """ +import datetime as dtm +from typing import Optional, Union + +from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import TimePeriod + __all__ = ( "BadRequest", "ChatMigrated", @@ -36,8 +43,6 @@ "TimedOut", ) -from typing import Optional, Union - class TelegramError(Exception): """ @@ -208,21 +213,42 @@ class RetryAfter(TelegramError): :attr:`retry_after` is now an integer to comply with the Bot API. Args: - retry_after (:obj:`int`): Time in seconds, after which the bot can retry the request. + retry_after (:obj:`int` | :class:`datetime.timedelta`): Time in seconds, after which the + bot can retry the request. + + .. versionchanged:: NEXT.VERSION + |time-period-input| Attributes: - retry_after (:obj:`int`): Time in seconds, after which the bot can retry the request. + retry_after (:obj:`int` | :class:`datetime.timedelta`): Time in seconds, after which the + bot can retry the request. + + .. deprecated:: NEXT.VERSION + |time-period-int-deprecated| """ - __slots__ = ("retry_after",) + __slots__ = ("_retry_after",) + + def __init__(self, retry_after: TimePeriod): + self._retry_after: dtm.timedelta = parse_period_arg( # type: ignore[assignment] + retry_after + ) + + if isinstance(self.retry_after, int): + super().__init__(f"Flood control exceeded. Retry in {self.retry_after} seconds") + else: + super().__init__(f"Flood control exceeded. Retry in {self.retry_after!s}") - def __init__(self, retry_after: int): - super().__init__(f"Flood control exceeded. Retry in {retry_after} seconds") - self.retry_after: int = retry_after + @property + def retry_after(self) -> Union[int, dtm.timedelta]: + """Time in seconds, after which the bot can retry the request.""" + value = get_timedelta_value(self._retry_after) + return int(value) if isinstance(value, float) else value # type: ignore[return-value] def __reduce__(self) -> tuple[type, tuple[float]]: # type: ignore[override] - return self.__class__, (self.retry_after,) + # Until support for `int` time periods is lifted, leave pickle behaviour the same + return self.__class__, (int(self._retry_after.total_seconds()),) class Conflict(TelegramError): diff --git a/tests/test_error.py b/tests/test_error.py index 9fd0ba707fc..8f1f6b1a145 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -16,6 +16,7 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm import pickle from collections import defaultdict @@ -35,6 +36,7 @@ TimedOut, ) from telegram.ext import InvalidCallbackData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -92,9 +94,28 @@ def test_chat_migrated(self): raise ChatMigrated(1234) assert e.value.new_chat_id == 1234 - def test_retry_after(self): - with pytest.raises(RetryAfter, match="Flood control exceeded. Retry in 12 seconds"): - raise RetryAfter(12) + @pytest.mark.parametrize("retry_after", [12, dtm.timedelta(seconds=12)]) + def test_retry_after(self, PTB_TIMEDELTA, retry_after): + if PTB_TIMEDELTA: + with pytest.raises(RetryAfter, match="Flood control exceeded. Retry in 0:00:12"): + raise (exception := RetryAfter(retry_after)) + assert type(exception.retry_after) is dtm.timedelta + else: + with pytest.raises(RetryAfter, match="Flood control exceeded. Retry in 12 seconds"): + raise (exception := RetryAfter(retry_after)) + assert type(exception.retry_after) is int + + def test_retry_after_int_deprecated(self, PTB_TIMEDELTA, recwarn): + retry_after = RetryAfter(12).retry_after + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + assert type(retry_after) is dtm.timedelta + else: + assert len(recwarn) == 1 + assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + assert type(retry_after) is int def test_conflict(self): with pytest.raises(Conflict, match="Something something."): @@ -111,6 +132,7 @@ def test_conflict(self): (TimedOut(), ["message"]), (ChatMigrated(1234), ["message", "new_chat_id"]), (RetryAfter(12), ["message", "retry_after"]), + (RetryAfter(dtm.timedelta(seconds=12)), ["message", "retry_after"]), (Conflict("test message"), ["message"]), (PassportDecryptionError("test message"), ["message"]), (InvalidCallbackData("test data"), ["callback_data"]), @@ -136,7 +158,7 @@ def test_errors_pickling(self, exception, attributes): (BadRequest("test message")), (TimedOut()), (ChatMigrated(1234)), - (RetryAfter(12)), + (RetryAfter(dtm.timedelta(seconds=12))), (Conflict("test message")), (PassportDecryptionError("test message")), (InvalidCallbackData("test data")), @@ -181,15 +203,19 @@ def make_assertion(cls): make_assertion(TelegramError) - def test_string_representations(self): + def test_string_representations(self, PTB_TIMEDELTA): """We just randomly test a few of the subclasses - should suffice""" e = TelegramError("This is a message") assert repr(e) == "TelegramError('This is a message')" assert str(e) == "This is a message" - e = RetryAfter(42) - assert repr(e) == "RetryAfter('Flood control exceeded. Retry in 42 seconds')" - assert str(e) == "Flood control exceeded. Retry in 42 seconds" + e = RetryAfter(dtm.timedelta(seconds=42)) + if PTB_TIMEDELTA: + assert repr(e) == "RetryAfter('Flood control exceeded. Retry in 0:00:42')" + assert str(e) == "Flood control exceeded. Retry in 0:00:42" + else: + assert repr(e) == "RetryAfter('Flood control exceeded. Retry in 42 seconds')" + assert str(e) == "Flood control exceeded. Retry in 42 seconds" e = BadRequest("This is a message") assert repr(e) == "BadRequest('This is a message')" From a4d4d121b67125159cfb0f8b7228b796fd64038c Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Fri, 23 May 2025 07:08:40 +0300 Subject: [PATCH 17/30] Include attribute name in warning message. --- telegram/_chatfullinfo.py | 6 ++++-- telegram/_chatinvitelink.py | 2 +- telegram/_files/animation.py | 2 +- telegram/_files/audio.py | 2 +- telegram/_files/inputmedia.py | 8 ++++---- telegram/_files/location.py | 2 +- telegram/_files/video.py | 4 ++-- telegram/_files/videonote.py | 2 +- telegram/_files/voice.py | 2 +- telegram/_inline/inlinequeryresultaudio.py | 2 +- telegram/_inline/inlinequeryresultgif.py | 2 +- telegram/_inline/inlinequeryresultlocation.py | 2 +- telegram/_inline/inlinequeryresultmpeg4gif.py | 2 +- telegram/_inline/inlinequeryresultvideo.py | 2 +- telegram/_inline/inlinequeryresultvoice.py | 2 +- .../_inline/inputlocationmessagecontent.py | 2 +- telegram/_messageautodeletetimerchanged.py | 4 +++- telegram/_paidmedia.py | 2 +- telegram/_poll.py | 2 +- telegram/_utils/datetime.py | 18 ++++++++++++++---- telegram/_videochat.py | 2 +- telegram/error.py | 2 +- tests/_files/test_animation.py | 2 +- tests/_files/test_audio.py | 2 +- tests/_files/test_inputmedia.py | 8 ++++---- tests/_files/test_location.py | 2 +- tests/_files/test_video.py | 4 ++-- tests/_files/test_videonote.py | 2 +- tests/_files/test_voice.py | 2 +- tests/_inline/test_inlinequeryresultaudio.py | 4 +++- tests/_inline/test_inlinequeryresultgif.py | 2 +- .../_inline/test_inlinequeryresultlocation.py | 2 +- .../_inline/test_inlinequeryresultmpeg4gif.py | 4 +++- tests/_inline/test_inlinequeryresultvideo.py | 4 +++- tests/_inline/test_inlinequeryresultvoice.py | 4 +++- .../test_inputlocationmessagecontent.py | 2 +- tests/test_chatfullinfo.py | 4 ++-- tests/test_chatinvitelink.py | 4 +++- tests/test_error.py | 2 +- tests/test_messageautodeletetimerchanged.py | 4 +++- tests/test_paidmedia.py | 2 +- tests/test_poll.py | 2 +- tests/test_videochat.py | 2 +- 43 files changed, 82 insertions(+), 56 deletions(-) diff --git a/telegram/_chatfullinfo.py b/telegram/_chatfullinfo.py index 143830d2f4d..510be4d7dfc 100644 --- a/telegram/_chatfullinfo.py +++ b/telegram/_chatfullinfo.py @@ -599,14 +599,16 @@ def can_send_gift(self) -> Optional[bool]: @property def slow_mode_delay(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._slow_mode_delay) + value = get_timedelta_value(self._slow_mode_delay, attribute="slow_mode_delay") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] @property def message_auto_delete_time(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._message_auto_delete_time) + value = get_timedelta_value( + self._message_auto_delete_time, attribute="message_auto_delete_time" + ) if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_chatinvitelink.py b/telegram/_chatinvitelink.py index e159c8cd9ad..6f413385e7f 100644 --- a/telegram/_chatinvitelink.py +++ b/telegram/_chatinvitelink.py @@ -189,7 +189,7 @@ def __init__( @property def subscription_period(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._subscription_period) + value = get_timedelta_value(self._subscription_period, attribute="subscription_period") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_files/animation.py b/telegram/_files/animation.py index 0fbab6edad3..2e9b2809a7b 100644 --- a/telegram/_files/animation.py +++ b/telegram/_files/animation.py @@ -117,7 +117,7 @@ def __init__( @property def duration(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value(self._duration) + value = get_timedelta_value(self._duration, attribute="duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_files/audio.py b/telegram/_files/audio.py index e8cd26c94b5..28e48ebfdb4 100644 --- a/telegram/_files/audio.py +++ b/telegram/_files/audio.py @@ -119,7 +119,7 @@ def __init__( @property def duration(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value(self._duration) + value = get_timedelta_value(self._duration, attribute="duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_files/inputmedia.py b/telegram/_files/inputmedia.py index 86add1e8745..f80c27be411 100644 --- a/telegram/_files/inputmedia.py +++ b/telegram/_files/inputmedia.py @@ -308,7 +308,7 @@ def __init__( @property def duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._duration) + value = get_timedelta_value(self._duration, attribute="duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] @@ -464,7 +464,7 @@ def __init__( @property def duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._duration) + value = get_timedelta_value(self._duration, attribute="duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] @@ -729,7 +729,7 @@ def __init__( @property def duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._duration) + value = get_timedelta_value(self._duration, attribute="duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] @@ -853,7 +853,7 @@ def __init__( @property def duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._duration) + value = get_timedelta_value(self._duration, attribute="duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_files/location.py b/telegram/_files/location.py index 565faee6bb6..770df7b933f 100644 --- a/telegram/_files/location.py +++ b/telegram/_files/location.py @@ -112,7 +112,7 @@ def __init__( @property def live_period(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._live_period) + value = get_timedelta_value(self._live_period, attribute="live_period") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_files/video.py b/telegram/_files/video.py index a9c510fbbf0..dee1a3c92f2 100644 --- a/telegram/_files/video.py +++ b/telegram/_files/video.py @@ -147,14 +147,14 @@ def __init__( @property def duration(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value(self._duration) + value = get_timedelta_value(self._duration, attribute="duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] @property def start_timestamp(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._start_timestamp) + value = get_timedelta_value(self._start_timestamp, attribute="start_timestamp") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_files/videonote.py b/telegram/_files/videonote.py index c2c21b310bc..c559f880a44 100644 --- a/telegram/_files/videonote.py +++ b/telegram/_files/videonote.py @@ -105,7 +105,7 @@ def __init__( @property def duration(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value(self._duration) + value = get_timedelta_value(self._duration, attribute="duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_files/voice.py b/telegram/_files/voice.py index 1da486b41d8..0be6065e5e0 100644 --- a/telegram/_files/voice.py +++ b/telegram/_files/voice.py @@ -91,7 +91,7 @@ def __init__( @property def duration(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value(self._duration) + value = get_timedelta_value(self._duration, attribute="duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_inline/inlinequeryresultaudio.py b/telegram/_inline/inlinequeryresultaudio.py index eadd015d637..2d4b7d7c1d9 100644 --- a/telegram/_inline/inlinequeryresultaudio.py +++ b/telegram/_inline/inlinequeryresultaudio.py @@ -141,7 +141,7 @@ def __init__( @property def audio_duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._audio_duration) + value = get_timedelta_value(self._audio_duration, attribute="audio_duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_inline/inlinequeryresultgif.py b/telegram/_inline/inlinequeryresultgif.py index 8ad4c46edd0..7297a008769 100644 --- a/telegram/_inline/inlinequeryresultgif.py +++ b/telegram/_inline/inlinequeryresultgif.py @@ -185,7 +185,7 @@ def __init__( @property def gif_duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._gif_duration) + value = get_timedelta_value(self._gif_duration, attribute="gif_duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_inline/inlinequeryresultlocation.py b/telegram/_inline/inlinequeryresultlocation.py index c3a4cdcd3c7..7fb24575e13 100644 --- a/telegram/_inline/inlinequeryresultlocation.py +++ b/telegram/_inline/inlinequeryresultlocation.py @@ -181,7 +181,7 @@ def __init__( @property def live_period(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._live_period) + value = get_timedelta_value(self._live_period, attribute="live_period") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_inline/inlinequeryresultmpeg4gif.py b/telegram/_inline/inlinequeryresultmpeg4gif.py index c55f91c58f2..8726bebde32 100644 --- a/telegram/_inline/inlinequeryresultmpeg4gif.py +++ b/telegram/_inline/inlinequeryresultmpeg4gif.py @@ -187,7 +187,7 @@ def __init__( @property def mpeg4_duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._mpeg4_duration) + value = get_timedelta_value(self._mpeg4_duration, attribute="mpeg4_duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_inline/inlinequeryresultvideo.py b/telegram/_inline/inlinequeryresultvideo.py index 0a76863aaff..beec8580ec4 100644 --- a/telegram/_inline/inlinequeryresultvideo.py +++ b/telegram/_inline/inlinequeryresultvideo.py @@ -193,7 +193,7 @@ def __init__( @property def video_duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._video_duration) + value = get_timedelta_value(self._video_duration, attribute="video_duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_inline/inlinequeryresultvoice.py b/telegram/_inline/inlinequeryresultvoice.py index c3b8f46604a..4248e11e0ec 100644 --- a/telegram/_inline/inlinequeryresultvoice.py +++ b/telegram/_inline/inlinequeryresultvoice.py @@ -138,7 +138,7 @@ def __init__( @property def voice_duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._voice_duration) + value = get_timedelta_value(self._voice_duration, attribute="voice_duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_inline/inputlocationmessagecontent.py b/telegram/_inline/inputlocationmessagecontent.py index ede72bad83b..72d5dd6c0a8 100644 --- a/telegram/_inline/inputlocationmessagecontent.py +++ b/telegram/_inline/inputlocationmessagecontent.py @@ -125,7 +125,7 @@ def __init__( @property def live_period(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._live_period) + value = get_timedelta_value(self._live_period, attribute="live_period") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_messageautodeletetimerchanged.py b/telegram/_messageautodeletetimerchanged.py index 657fdf268cb..c199716aafa 100644 --- a/telegram/_messageautodeletetimerchanged.py +++ b/telegram/_messageautodeletetimerchanged.py @@ -75,7 +75,9 @@ def __init__( @property def message_auto_delete_time(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value(self._message_auto_delete_time) + value = get_timedelta_value( + self._message_auto_delete_time, attribute="message_auto_delete_time" + ) if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_paidmedia.py b/telegram/_paidmedia.py index 52535ab9c29..58ba766cd90 100644 --- a/telegram/_paidmedia.py +++ b/telegram/_paidmedia.py @@ -166,7 +166,7 @@ def __init__( @property def duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._duration) + value = get_timedelta_value(self._duration, attribute="duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_poll.py b/telegram/_poll.py index fc722884e32..e640c4ea63a 100644 --- a/telegram/_poll.py +++ b/telegram/_poll.py @@ -475,7 +475,7 @@ def __init__( @property def open_period(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._open_period) + value = get_timedelta_value(self._open_period, attribute="open_period") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/_utils/datetime.py b/telegram/_utils/datetime.py index aa977f8a423..02cc11b94d5 100644 --- a/telegram/_utils/datetime.py +++ b/telegram/_utils/datetime.py @@ -231,13 +231,22 @@ def _datetime_to_float_timestamp(dt_obj: dtm.datetime) -> float: return dt_obj.timestamp() -def get_timedelta_value(value: Optional[dtm.timedelta]) -> Optional[Union[float, dtm.timedelta]]: +def get_timedelta_value( + value: Optional[dtm.timedelta], attribute: str +) -> Optional[Union[float, dtm.timedelta]]: """ Convert a `datetime.timedelta` to seconds or return it as-is, based on environment config. This utility is part of the migration process from integer-based time representations to using `datetime.timedelta`. The behavior is controlled by the `PTB_TIMEDELTA` - environment variable + environment variable. + + Args: + value: The timedelta value to process. + attribute: The name of the attribute being processed, used for warning messages. + + Returns: + :obj:`dtm.timedelta` when `PTB_TIMEDELTA=true`, otherwise :obj:`float`. """ if value is None: return None @@ -248,8 +257,9 @@ def get_timedelta_value(value: Optional[dtm.timedelta]) -> Optional[Union[float, warn( PTBDeprecationWarning( "NEXT.VERSION", - "In a future major version this will be of type `datetime.timedelta`." - " You can opt-in early by setting the `PTB_TIMEDELTA` environment variable.", + f"In a future major version attribute `{attribute}` will be of type" + " `datetime.timedelta`. You can opt-in early by setting `PTB_TIMEDELTA=true`" + " as an environment variable.", ), stacklevel=2, ) diff --git a/telegram/_videochat.py b/telegram/_videochat.py index 7c2a74281a0..20cd7b3a73b 100644 --- a/telegram/_videochat.py +++ b/telegram/_videochat.py @@ -101,7 +101,7 @@ def __init__( @property def duration(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value(self._duration) + value = get_timedelta_value(self._duration, attribute="duration") if isinstance(value, float) and value.is_integer(): value = int(value) return value # type: ignore[return-value] diff --git a/telegram/error.py b/telegram/error.py index 140ba778089..08c688eada7 100644 --- a/telegram/error.py +++ b/telegram/error.py @@ -243,7 +243,7 @@ def __init__(self, retry_after: TimePeriod): @property def retry_after(self) -> Union[int, dtm.timedelta]: """Time in seconds, after which the bot can retry the request.""" - value = get_timedelta_value(self._retry_after) + value = get_timedelta_value(self._retry_after, attribute="retry_after") return int(value) if isinstance(value, float) else value # type: ignore[return-value] def __reduce__(self) -> tuple[type, tuple[float]]: # type: ignore[override] diff --git a/tests/_files/test_animation.py b/tests/_files/test_animation.py index 654687f224b..50437e69877 100644 --- a/tests/_files/test_animation.py +++ b/tests/_files/test_animation.py @@ -123,7 +123,7 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, animation): assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): diff --git a/tests/_files/test_audio.py b/tests/_files/test_audio.py index 5e8d14fa907..47d8dff9c2f 100644 --- a/tests/_files/test_audio.py +++ b/tests/_files/test_audio.py @@ -133,7 +133,7 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, audio): assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self, audio): diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index 3b7aa9535e6..08bdf3428a3 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -224,7 +224,7 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_vi assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_with_video(self, video, PTB_TIMEDELTA): @@ -410,7 +410,7 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_an assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_with_animation(self, animation): @@ -500,7 +500,7 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_au assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_with_audio(self, audio): @@ -683,7 +683,7 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_paid_med assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_with_video(self, video): diff --git a/tests/_files/test_location.py b/tests/_files/test_location.py index d96fd11297d..30cfb20595f 100644 --- a/tests/_files/test_location.py +++ b/tests/_files/test_location.py @@ -102,7 +102,7 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, location): assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`live_period` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): diff --git a/tests/_files/test_video.py b/tests/_files/test_video.py index 20c7404b615..ee2bfd81aa1 100644 --- a/tests/_files/test_video.py +++ b/tests/_files/test_video.py @@ -158,8 +158,8 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, video): assert len(recwarn) == 0 else: assert len(recwarn) == 2 - for i in range(2): - assert "will be of type `datetime.timedelta`" in str(recwarn[i].message) + for i, attr in enumerate(["duration", "start_timestamp"]): + assert f"`{attr}` will be of type `datetime.timedelta`" in str(recwarn[i].message) assert recwarn[i].category is PTBDeprecationWarning def test_equality(self, video): diff --git a/tests/_files/test_videonote.py b/tests/_files/test_videonote.py index 26e56227119..40f853bca52 100644 --- a/tests/_files/test_videonote.py +++ b/tests/_files/test_videonote.py @@ -125,7 +125,7 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, video_note): assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self, video_note): diff --git a/tests/_files/test_voice.py b/tests/_files/test_voice.py index eb8ec0358f1..62fdb4e79f8 100644 --- a/tests/_files/test_voice.py +++ b/tests/_files/test_voice.py @@ -123,7 +123,7 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, voice): assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self, voice): diff --git a/tests/_inline/test_inlinequeryresultaudio.py b/tests/_inline/test_inlinequeryresultaudio.py index 31a7d027422..17871fa854d 100644 --- a/tests/_inline/test_inlinequeryresultaudio.py +++ b/tests/_inline/test_inlinequeryresultaudio.py @@ -134,7 +134,9 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_r assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`audio_duration` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): diff --git a/tests/_inline/test_inlinequeryresultgif.py b/tests/_inline/test_inlinequeryresultgif.py index 5bcdda388fb..2806e895623 100644 --- a/tests/_inline/test_inlinequeryresultgif.py +++ b/tests/_inline/test_inlinequeryresultgif.py @@ -157,7 +157,7 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_r assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`gif_duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): diff --git a/tests/_inline/test_inlinequeryresultlocation.py b/tests/_inline/test_inlinequeryresultlocation.py index 9cc97fcf28d..a9471f0d55d 100644 --- a/tests/_inline/test_inlinequeryresultlocation.py +++ b/tests/_inline/test_inlinequeryresultlocation.py @@ -160,7 +160,7 @@ def test_time_period_int_deprecated( assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`live_period` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): diff --git a/tests/_inline/test_inlinequeryresultmpeg4gif.py b/tests/_inline/test_inlinequeryresultmpeg4gif.py index cf666316fb5..4c8291c4e5a 100644 --- a/tests/_inline/test_inlinequeryresultmpeg4gif.py +++ b/tests/_inline/test_inlinequeryresultmpeg4gif.py @@ -176,7 +176,9 @@ def test_time_period_int_deprecated( assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`mpeg4_duration` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): diff --git a/tests/_inline/test_inlinequeryresultvideo.py b/tests/_inline/test_inlinequeryresultvideo.py index 7c040cd5763..dd07b9c9719 100644 --- a/tests/_inline/test_inlinequeryresultvideo.py +++ b/tests/_inline/test_inlinequeryresultvideo.py @@ -168,7 +168,9 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_r assert isinstance(value, dtm.timedelta) else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`video_duration` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) assert recwarn[0].category is PTBDeprecationWarning assert isinstance(value, int) diff --git a/tests/_inline/test_inlinequeryresultvoice.py b/tests/_inline/test_inlinequeryresultvoice.py index 1f7fe47cda4..f4e58cca371 100644 --- a/tests/_inline/test_inlinequeryresultvoice.py +++ b/tests/_inline/test_inlinequeryresultvoice.py @@ -134,7 +134,9 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_r assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`voice_duration` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): diff --git a/tests/_inline/test_inputlocationmessagecontent.py b/tests/_inline/test_inputlocationmessagecontent.py index c57e1c157f6..1fd79ee9ad0 100644 --- a/tests/_inline/test_inputlocationmessagecontent.py +++ b/tests/_inline/test_inputlocationmessagecontent.py @@ -109,7 +109,7 @@ def test_time_period_int_deprecated( assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`live_period` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): diff --git a/tests/test_chatfullinfo.py b/tests/test_chatfullinfo.py index cb1848cfd81..52444fcbd34 100644 --- a/tests/test_chatfullinfo.py +++ b/tests/test_chatfullinfo.py @@ -388,8 +388,8 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, chat_full_info assert len(recwarn) == 0 else: assert len(recwarn) == 2 - for i in range(2): - assert "will be of type `datetime.timedelta`" in str(recwarn[i].message) + for i, attr in enumerate(["slow_mode_delay", "message_auto_delete_time"]): + assert f"`{attr}` will be of type `datetime.timedelta`" in str(recwarn[i].message) assert recwarn[i].category is PTBDeprecationWarning def test_always_tuples_attributes(self): diff --git a/tests/test_chatinvitelink.py b/tests/test_chatinvitelink.py index b8627af8adc..f111d7bf2b6 100644 --- a/tests/test_chatinvitelink.py +++ b/tests/test_chatinvitelink.py @@ -176,7 +176,9 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, invite_link): assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`subscription_period` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): diff --git a/tests/test_error.py b/tests/test_error.py index 8f1f6b1a145..863ec0c4c5e 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -113,7 +113,7 @@ def test_retry_after_int_deprecated(self, PTB_TIMEDELTA, recwarn): assert type(retry_after) is dtm.timedelta else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`retry_after` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning assert type(retry_after) is int diff --git a/tests/test_messageautodeletetimerchanged.py b/tests/test_messageautodeletetimerchanged.py index 19d2e8b99c5..9e0ab16476f 100644 --- a/tests/test_messageautodeletetimerchanged.py +++ b/tests/test_messageautodeletetimerchanged.py @@ -70,7 +70,9 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA): assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`message_auto_delete_time` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): diff --git a/tests/test_paidmedia.py b/tests/test_paidmedia.py index e2a9af11abd..8055e161e84 100644 --- a/tests/test_paidmedia.py +++ b/tests/test_paidmedia.py @@ -313,7 +313,7 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, paid_media_pre assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning diff --git a/tests/test_poll.py b/tests/test_poll.py index 7a8203aa268..484e18710a2 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -407,7 +407,7 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, poll): assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`open_period` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): diff --git a/tests/test_videochat.py b/tests/test_videochat.py index 61e042d8e60..df8151940cf 100644 --- a/tests/test_videochat.py +++ b/tests/test_videochat.py @@ -100,7 +100,7 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA): assert len(recwarn) == 0 else: assert len(recwarn) == 1 - assert "will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): From 319484876071571f57d1b6fffe9fe9fc7fc973bb Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Fri, 23 May 2025 08:06:02 +0300 Subject: [PATCH 18/30] review: refactor `_utils/datetime.get_timedelta_value`. --- telegram/_chatfullinfo.py | 10 ++------ telegram/_chatinvitelink.py | 5 +--- telegram/_files/animation.py | 7 +++--- telegram/_files/audio.py | 7 +++--- telegram/_files/inputmedia.py | 20 ++++------------ telegram/_files/location.py | 5 +--- telegram/_files/video.py | 12 ++++------ telegram/_files/videonote.py | 7 +++--- telegram/_files/voice.py | 7 +++--- telegram/_inline/inlinequeryresultaudio.py | 5 +--- telegram/_inline/inlinequeryresultgif.py | 5 +--- telegram/_inline/inlinequeryresultlocation.py | 5 +--- telegram/_inline/inlinequeryresultmpeg4gif.py | 5 +--- telegram/_inline/inlinequeryresultvideo.py | 5 +--- telegram/_inline/inlinequeryresultvoice.py | 5 +--- .../_inline/inputlocationmessagecontent.py | 5 +--- telegram/_messageautodeletetimerchanged.py | 5 +--- telegram/_paidmedia.py | 5 +--- telegram/_poll.py | 5 +--- telegram/_utils/datetime.py | 23 +++++++++++++------ telegram/_videochat.py | 7 +++--- telegram/error.py | 5 ++-- 22 files changed, 56 insertions(+), 109 deletions(-) diff --git a/telegram/_chatfullinfo.py b/telegram/_chatfullinfo.py index 510be4d7dfc..8db9b5468d1 100644 --- a/telegram/_chatfullinfo.py +++ b/telegram/_chatfullinfo.py @@ -599,19 +599,13 @@ def can_send_gift(self) -> Optional[bool]: @property def slow_mode_delay(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._slow_mode_delay, attribute="slow_mode_delay") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._slow_mode_delay, attribute="slow_mode_delay") @property def message_auto_delete_time(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value( + return get_timedelta_value( self._message_auto_delete_time, attribute="message_auto_delete_time" ) - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo": diff --git a/telegram/_chatinvitelink.py b/telegram/_chatinvitelink.py index 6f413385e7f..f11cc7bea90 100644 --- a/telegram/_chatinvitelink.py +++ b/telegram/_chatinvitelink.py @@ -189,10 +189,7 @@ def __init__( @property def subscription_period(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._subscription_period, attribute="subscription_period") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._subscription_period, attribute="subscription_period") @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatInviteLink": diff --git a/telegram/_files/animation.py b/telegram/_files/animation.py index 2e9b2809a7b..75bc4297fe8 100644 --- a/telegram/_files/animation.py +++ b/telegram/_files/animation.py @@ -117,10 +117,9 @@ def __init__( @property def duration(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value(self._duration, attribute="duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Animation": diff --git a/telegram/_files/audio.py b/telegram/_files/audio.py index 28e48ebfdb4..47cb467f322 100644 --- a/telegram/_files/audio.py +++ b/telegram/_files/audio.py @@ -119,10 +119,9 @@ def __init__( @property def duration(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value(self._duration, attribute="duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Audio": diff --git a/telegram/_files/inputmedia.py b/telegram/_files/inputmedia.py index f80c27be411..39fc279afc1 100644 --- a/telegram/_files/inputmedia.py +++ b/telegram/_files/inputmedia.py @@ -308,10 +308,7 @@ def __init__( @property def duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._duration, attribute="duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._duration, attribute="duration") def to_dict(self, recursive: bool = True) -> JSONDict: """See :meth:`telegram.TelegramObject.to_dict`.""" @@ -464,10 +461,7 @@ def __init__( @property def duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._duration, attribute="duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._duration, attribute="duration") class InputMediaPhoto(InputMedia): @@ -729,10 +723,7 @@ def __init__( @property def duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._duration, attribute="duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._duration, attribute="duration") class InputMediaAudio(InputMedia): @@ -853,10 +844,7 @@ def __init__( @property def duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._duration, attribute="duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._duration, attribute="duration") class InputMediaDocument(InputMedia): diff --git a/telegram/_files/location.py b/telegram/_files/location.py index 770df7b933f..d657e24a368 100644 --- a/telegram/_files/location.py +++ b/telegram/_files/location.py @@ -112,10 +112,7 @@ def __init__( @property def live_period(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._live_period, attribute="live_period") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._live_period, attribute="live_period") @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Location": diff --git a/telegram/_files/video.py b/telegram/_files/video.py index dee1a3c92f2..1a1a523472d 100644 --- a/telegram/_files/video.py +++ b/telegram/_files/video.py @@ -147,17 +147,13 @@ def __init__( @property def duration(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value(self._duration, attribute="duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) @property def start_timestamp(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._start_timestamp, attribute="start_timestamp") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._start_timestamp, attribute="start_timestamp") @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Video": diff --git a/telegram/_files/videonote.py b/telegram/_files/videonote.py index c559f880a44..ef5770cde4c 100644 --- a/telegram/_files/videonote.py +++ b/telegram/_files/videonote.py @@ -105,10 +105,9 @@ def __init__( @property def duration(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value(self._duration, attribute="duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "VideoNote": diff --git a/telegram/_files/voice.py b/telegram/_files/voice.py index 0be6065e5e0..07d125ba334 100644 --- a/telegram/_files/voice.py +++ b/telegram/_files/voice.py @@ -91,10 +91,9 @@ def __init__( @property def duration(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value(self._duration, attribute="duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Voice": diff --git a/telegram/_inline/inlinequeryresultaudio.py b/telegram/_inline/inlinequeryresultaudio.py index 2d4b7d7c1d9..600bedda378 100644 --- a/telegram/_inline/inlinequeryresultaudio.py +++ b/telegram/_inline/inlinequeryresultaudio.py @@ -141,10 +141,7 @@ def __init__( @property def audio_duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._audio_duration, attribute="audio_duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._audio_duration, attribute="audio_duration") def to_dict(self, recursive: bool = True) -> JSONDict: """See :meth:`telegram.TelegramObject.to_dict`.""" diff --git a/telegram/_inline/inlinequeryresultgif.py b/telegram/_inline/inlinequeryresultgif.py index 7297a008769..494d338f0f1 100644 --- a/telegram/_inline/inlinequeryresultgif.py +++ b/telegram/_inline/inlinequeryresultgif.py @@ -185,10 +185,7 @@ def __init__( @property def gif_duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._gif_duration, attribute="gif_duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._gif_duration, attribute="gif_duration") def to_dict(self, recursive: bool = True) -> JSONDict: """See :meth:`telegram.TelegramObject.to_dict`.""" diff --git a/telegram/_inline/inlinequeryresultlocation.py b/telegram/_inline/inlinequeryresultlocation.py index 7fb24575e13..9c061722067 100644 --- a/telegram/_inline/inlinequeryresultlocation.py +++ b/telegram/_inline/inlinequeryresultlocation.py @@ -181,10 +181,7 @@ def __init__( @property def live_period(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._live_period, attribute="live_period") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._live_period, attribute="live_period") def to_dict(self, recursive: bool = True) -> JSONDict: """See :meth:`telegram.TelegramObject.to_dict`.""" diff --git a/telegram/_inline/inlinequeryresultmpeg4gif.py b/telegram/_inline/inlinequeryresultmpeg4gif.py index 8726bebde32..e1d7ea08537 100644 --- a/telegram/_inline/inlinequeryresultmpeg4gif.py +++ b/telegram/_inline/inlinequeryresultmpeg4gif.py @@ -187,10 +187,7 @@ def __init__( @property def mpeg4_duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._mpeg4_duration, attribute="mpeg4_duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._mpeg4_duration, attribute="mpeg4_duration") def to_dict(self, recursive: bool = True) -> JSONDict: """See :meth:`telegram.TelegramObject.to_dict`.""" diff --git a/telegram/_inline/inlinequeryresultvideo.py b/telegram/_inline/inlinequeryresultvideo.py index beec8580ec4..846b63d4167 100644 --- a/telegram/_inline/inlinequeryresultvideo.py +++ b/telegram/_inline/inlinequeryresultvideo.py @@ -193,10 +193,7 @@ def __init__( @property def video_duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._video_duration, attribute="video_duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._video_duration, attribute="video_duration") def to_dict(self, recursive: bool = True) -> JSONDict: """See :meth:`telegram.TelegramObject.to_dict`.""" diff --git a/telegram/_inline/inlinequeryresultvoice.py b/telegram/_inline/inlinequeryresultvoice.py index 4248e11e0ec..a0d7511d751 100644 --- a/telegram/_inline/inlinequeryresultvoice.py +++ b/telegram/_inline/inlinequeryresultvoice.py @@ -138,10 +138,7 @@ def __init__( @property def voice_duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._voice_duration, attribute="voice_duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._voice_duration, attribute="voice_duration") def to_dict(self, recursive: bool = True) -> JSONDict: """See :meth:`telegram.TelegramObject.to_dict`.""" diff --git a/telegram/_inline/inputlocationmessagecontent.py b/telegram/_inline/inputlocationmessagecontent.py index 72d5dd6c0a8..6d6a71f0637 100644 --- a/telegram/_inline/inputlocationmessagecontent.py +++ b/telegram/_inline/inputlocationmessagecontent.py @@ -125,10 +125,7 @@ def __init__( @property def live_period(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._live_period, attribute="live_period") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._live_period, attribute="live_period") def to_dict(self, recursive: bool = True) -> JSONDict: """See :meth:`telegram.TelegramObject.to_dict`.""" diff --git a/telegram/_messageautodeletetimerchanged.py b/telegram/_messageautodeletetimerchanged.py index c199716aafa..a636cc9a9e5 100644 --- a/telegram/_messageautodeletetimerchanged.py +++ b/telegram/_messageautodeletetimerchanged.py @@ -75,12 +75,9 @@ def __init__( @property def message_auto_delete_time(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value( + return get_timedelta_value( # type: ignore[return-value] self._message_auto_delete_time, attribute="message_auto_delete_time" ) - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] @classmethod def de_json( diff --git a/telegram/_paidmedia.py b/telegram/_paidmedia.py index 58ba766cd90..ec2afd6aaff 100644 --- a/telegram/_paidmedia.py +++ b/telegram/_paidmedia.py @@ -166,10 +166,7 @@ def __init__( @property def duration(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._duration, attribute="duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._duration, attribute="duration") def to_dict(self, recursive: bool = True) -> JSONDict: """See :meth:`telegram.TelegramObject.to_dict`.""" diff --git a/telegram/_poll.py b/telegram/_poll.py index e640c4ea63a..22dffa52edb 100644 --- a/telegram/_poll.py +++ b/telegram/_poll.py @@ -475,10 +475,7 @@ def __init__( @property def open_period(self) -> Optional[Union[int, dtm.timedelta]]: - value = get_timedelta_value(self._open_period, attribute="open_period") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value(self._open_period, attribute="open_period") @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Poll": diff --git a/telegram/_utils/datetime.py b/telegram/_utils/datetime.py index 02cc11b94d5..891e3481086 100644 --- a/telegram/_utils/datetime.py +++ b/telegram/_utils/datetime.py @@ -233,7 +233,7 @@ def _datetime_to_float_timestamp(dt_obj: dtm.datetime) -> float: def get_timedelta_value( value: Optional[dtm.timedelta], attribute: str -) -> Optional[Union[float, dtm.timedelta]]: +) -> Optional[Union[int, dtm.timedelta]]: """ Convert a `datetime.timedelta` to seconds or return it as-is, based on environment config. @@ -241,19 +241,24 @@ def get_timedelta_value( to using `datetime.timedelta`. The behavior is controlled by the `PTB_TIMEDELTA` environment variable. + Note: + When `PTB_TIMEDELTA` is not enabled, the function will issue a deprecation warning. + Args: - value: The timedelta value to process. - attribute: The name of the attribute being processed, used for warning messages. + value (:obj:`datetime.timedelta`): The timedelta value to process. + attribute (:obj:`str`): The name of the attribute at the caller scope, used for + warning messages. Returns: - :obj:`dtm.timedelta` when `PTB_TIMEDELTA=true`, otherwise :obj:`float`. + - :obj:`None` if :paramref:`value` is None. + - :obj:`datetime.timedelta` if `PTB_TIMEDELTA=true`. + - :obj:`int` if the total seconds is a whole number. + - float: otherwise. """ if value is None: return None - if env_var_2_bool(os.getenv("PTB_TIMEDELTA")): return value - warn( PTBDeprecationWarning( "NEXT.VERSION", @@ -263,4 +268,8 @@ def get_timedelta_value( ), stacklevel=2, ) - return value.total_seconds() + return ( + int(seconds) + if (seconds := value.total_seconds()).is_integer() + else seconds # type: ignore[return-value] + ) diff --git a/telegram/_videochat.py b/telegram/_videochat.py index 20cd7b3a73b..c369e2d93f2 100644 --- a/telegram/_videochat.py +++ b/telegram/_videochat.py @@ -101,10 +101,9 @@ def __init__( @property def duration(self) -> Union[int, dtm.timedelta]: - value = get_timedelta_value(self._duration, attribute="duration") - if isinstance(value, float) and value.is_integer(): - value = int(value) - return value # type: ignore[return-value] + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "VideoChatEnded": diff --git a/telegram/error.py b/telegram/error.py index 08c688eada7..dea0dd850ce 100644 --- a/telegram/error.py +++ b/telegram/error.py @@ -243,8 +243,9 @@ def __init__(self, retry_after: TimePeriod): @property def retry_after(self) -> Union[int, dtm.timedelta]: """Time in seconds, after which the bot can retry the request.""" - value = get_timedelta_value(self._retry_after, attribute="retry_after") - return int(value) if isinstance(value, float) else value # type: ignore[return-value] + return get_timedelta_value( # type: ignore[return-value] + self._retry_after, attribute="retry_after" + ) def __reduce__(self) -> tuple[type, tuple[float]]: # type: ignore[override] # Until support for `int` time periods is lifted, leave pickle behaviour the same From de83ac6de95e1f0ef1463a75a7a272bdae5cbe42 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Fri, 23 May 2025 08:19:35 +0300 Subject: [PATCH 19/30] Remove temporarily time period parser introduced in #4769. --- telegram/_files/_inputstorycontent.py | 15 +++------------ telegram/_utils/argumentparsing.py | 8 ++++---- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/telegram/_files/_inputstorycontent.py b/telegram/_files/_inputstorycontent.py index 1eaf14682f3..4481ecf9814 100644 --- a/telegram/_files/_inputstorycontent.py +++ b/telegram/_files/_inputstorycontent.py @@ -25,6 +25,7 @@ from telegram._files.inputfile import InputFile from telegram._telegramobject import TelegramObject from telegram._utils import enum +from telegram._utils.argumentparsing import parse_period_arg from telegram._utils.files import parse_file_input from telegram._utils.types import FileInput, JSONDict @@ -158,18 +159,8 @@ def __init__( with self._unfrozen(): self.video: Union[str, InputFile] = self._parse_file_input(video) - self.duration: Optional[dtm.timedelta] = self._parse_period_arg(duration) - self.cover_frame_timestamp: Optional[dtm.timedelta] = self._parse_period_arg( + self.duration: Optional[dtm.timedelta] = parse_period_arg(duration) + self.cover_frame_timestamp: Optional[dtm.timedelta] = parse_period_arg( cover_frame_timestamp ) self.is_animation: Optional[bool] = is_animation - - # This helper is temporarly here until we can use `argumentparsing.parse_period_arg` - # from https://github.com/python-telegram-bot/python-telegram-bot/pull/4750 - @staticmethod - def _parse_period_arg(arg: Optional[Union[float, dtm.timedelta]]) -> Optional[dtm.timedelta]: - if arg is None: - return None - if isinstance(arg, dtm.timedelta): - return arg - return dtm.timedelta(seconds=arg) diff --git a/telegram/_utils/argumentparsing.py b/telegram/_utils/argumentparsing.py index 8d981c1439d..70ef767cbed 100644 --- a/telegram/_utils/argumentparsing.py +++ b/telegram/_utils/argumentparsing.py @@ -25,11 +25,11 @@ """ import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional, Protocol, TypeVar +from typing import TYPE_CHECKING, Optional, Protocol, TypeVar, Union from telegram._linkpreviewoptions import LinkPreviewOptions from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict, ODVInput, TimePeriod +from telegram._utils.types import JSONDict, ODVInput if TYPE_CHECKING: from typing import type_check_only @@ -51,7 +51,7 @@ def parse_sequence_arg(arg: Optional[Sequence[T]]) -> tuple[T, ...]: return tuple(arg) if arg else () -def parse_period_arg(arg: Optional[TimePeriod]) -> Optional[dtm.timedelta]: +def parse_period_arg(arg: Optional[Union[int, float, dtm.timedelta]]) -> Optional[dtm.timedelta]: """Parses an optional time period in seconds into a timedelta Args: @@ -62,7 +62,7 @@ def parse_period_arg(arg: Optional[TimePeriod]) -> Optional[dtm.timedelta]: """ if arg is None: return None - if isinstance(arg, int): + if isinstance(arg, (int, float)): return dtm.timedelta(seconds=arg) return arg From 10e716a6f0bb658869b52239af6bc8f0059cae29 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sat, 24 May 2025 06:47:51 +0300 Subject: [PATCH 20/30] review: address comments --- changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml | 6 +++--- docs/substitutions/global.rst | 2 +- telegram/_bot.py | 2 +- telegram/_files/video.py | 2 +- telegram/error.py | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml b/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml index c77500c10bb..ae225ae88d3 100644 --- a/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml +++ b/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml @@ -1,7 +1,7 @@ features = "Use `timedelta` to represent time periods in classes" -deprecations = """In this release, we're migrating attributes that represent durations/time periods from :ob:`int` type to Python's native :class:`datetime.timedelta`. +deprecations = """In this release, we're migrating attributes of Telegram objects that represent durations/time periods from having :obj:`int` type to Python's native :class:`datetime.timedelta`. -Enable the ``PTB_TIMEDELTA`` environment variable to adopt :obj:`timedelta` now. Support for :obj:`int` values is deprecated and will be removed in a future major version. +Set ``PTB_TIMEDELTA=true`` as an environment variable to make these attributes return :obj:`datetime.timedelta` objects instead of integers. Support for :obj:`int` values is deprecated and will be removed in a future major version. Affected Attributes: - :attr:`telegram.ChatFullInfo.slow_mode_delay` and :attr:`telegram.ChatFullInfo.message_auto_delete_time` @@ -29,7 +29,7 @@ Affected Attributes: - :attr:`telegram.InputLocationMessageContent.live_period` - :attr:`telegram.error.RetryAfter.retry_after` """ -internal = "Modify test_official to handle time periods as timedelta automatically." +internal = "Modify `test_official` to handle time periods as timedelta automatically." [[pull_requests]] uid = "4750" author_uid = "aelkheir" diff --git a/docs/substitutions/global.rst b/docs/substitutions/global.rst index 2ff72bcda9f..88cf095caa3 100644 --- a/docs/substitutions/global.rst +++ b/docs/substitutions/global.rst @@ -102,4 +102,4 @@ .. |time-period-input| replace:: :class:`datetime.timedelta` objects are accepted in addition to plain :obj:`int` values. -.. |time-period-int-deprecated| replace:: In a future major version this will be of type :obj:`datetime.timedelta`. You can opt-in early by setting the `PTB_TIMEDELTA` environment variable. +.. |time-period-int-deprecated| replace:: In a future major version this attribute will be of type :obj:`datetime.timedelta`. You can opt-in early by setting `PTB_TIMEDELTA=true` as an environment variable. diff --git a/telegram/_bot.py b/telegram/_bot.py index a72fc10b796..5f588f430a8 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -4597,7 +4597,7 @@ async def get_updates( read_timeout = ( (arg_read_timeout + timeout.total_seconds()) if isinstance(timeout, dtm.timedelta) - else (arg_read_timeout + timeout if isinstance(timeout, int) else arg_read_timeout) + else (arg_read_timeout + timeout if timeout else arg_read_timeout) ) # Ideally we'd use an aggressive read timeout for the polling. However, diff --git a/telegram/_files/video.py b/telegram/_files/video.py index 1a1a523472d..cc122be5127 100644 --- a/telegram/_files/video.py +++ b/telegram/_files/video.py @@ -93,7 +93,7 @@ class Video(_BaseThumbedMedium): the video in the message. .. versionadded:: 21.11 - start_timestamp (:obj:`int` | :class:`datetime.timedelta`): Optional, Timestamp in seconds + start_timestamp (:obj:`int` | :class:`datetime.timedelta`): Optional. Timestamp in seconds from which the video will play in the message .. versionadded:: 21.11 diff --git a/telegram/error.py b/telegram/error.py index dea0dd850ce..309836febd5 100644 --- a/telegram/error.py +++ b/telegram/error.py @@ -241,8 +241,8 @@ def __init__(self, retry_after: TimePeriod): super().__init__(f"Flood control exceeded. Retry in {self.retry_after!s}") @property - def retry_after(self) -> Union[int, dtm.timedelta]: - """Time in seconds, after which the bot can retry the request.""" + def retry_after(self) -> Union[int, dtm.timedelta]: # noqa: D102 + # Diableing D102 because docstring for `retry_after` is present at the class's level return get_timedelta_value( # type: ignore[return-value] self._retry_after, attribute="retry_after" ) From b20f9f9f2bb6c1be7bfd4cf7456dc7ac2cf4fc5a Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sat, 24 May 2025 10:40:55 +0300 Subject: [PATCH 21/30] Fix precommit and update `test_request.py`. --- telegram/ext/_aioratelimiter.py | 2 +- telegram/ext/_utils/networkloop.py | 3 ++- tests/request/test_request.py | 9 ++++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/telegram/ext/_aioratelimiter.py b/telegram/ext/_aioratelimiter.py index f4ecf917f66..d2d537e7e27 100644 --- a/telegram/ext/_aioratelimiter.py +++ b/telegram/ext/_aioratelimiter.py @@ -288,7 +288,7 @@ async def process_request( ) raise - sleep = exc.retry_after + 0.1 + sleep = exc._retry_after.total_seconds() + 0.1 # pylint: disable=protected-access _LOGGER.info("Rate limit hit. Retrying after %f seconds", sleep) # Make sure we don't allow other requests to be processed self._retry_after_event.clear() diff --git a/telegram/ext/_utils/networkloop.py b/telegram/ext/_utils/networkloop.py index 03c54e8e8a2..2cc93113272 100644 --- a/telegram/ext/_utils/networkloop.py +++ b/telegram/ext/_utils/networkloop.py @@ -119,7 +119,8 @@ async def do_action() -> bool: _LOGGER.info( "%s %s. Adding %s seconds to the specified time.", log_prefix, exc, slack_time ) - cur_interval = slack_time + exc.retry_after + # pylint: disable=protected-access + cur_interval = slack_time + exc._retry_after.total_seconds() except TimedOut as toe: _LOGGER.debug("%s Timed out: %s. Retrying immediately.", log_prefix, toe) # If failure is due to timeout, we should retry asap. diff --git a/tests/request/test_request.py b/tests/request/test_request.py index 1672b8fb64e..45d18ef0c59 100644 --- a/tests/request/test_request.py +++ b/tests/request/test_request.py @@ -19,6 +19,7 @@ """Here we run tests directly with HTTPXRequest because that's easier than providing dummy implementations for BaseRequest and we want to test HTTPXRequest anyway.""" import asyncio +import datetime as dtm import json import logging from collections import defaultdict @@ -244,7 +245,7 @@ async def test_chat_migrated(self, monkeypatch, httpx_request: HTTPXRequest): assert exc_info.value.new_chat_id == 123 - async def test_retry_after(self, monkeypatch, httpx_request: HTTPXRequest): + async def test_retry_after(self, monkeypatch, httpx_request: HTTPXRequest, PTB_TIMEDELTA): server_response = b'{"ok": "False", "parameters": {"retry_after": 42}}' monkeypatch.setattr( @@ -253,10 +254,12 @@ async def test_retry_after(self, monkeypatch, httpx_request: HTTPXRequest): mocker_factory(response=server_response, return_code=HTTPStatus.BAD_REQUEST), ) - with pytest.raises(RetryAfter, match="Retry in 42") as exc_info: + with pytest.raises( + RetryAfter, match="Retry in " + "0:00:42" if PTB_TIMEDELTA else "42" + ) as exc_info: await httpx_request.post(None, None, None) - assert exc_info.value.retry_after == 42 + assert exc_info.value.retry_after == (dtm.timdelta(seconds=42) if PTB_TIMEDELTA else 42) async def test_unknown_request_params(self, monkeypatch, httpx_request: HTTPXRequest): server_response = b'{"ok": "False", "parameters": {"unknown": "42"}}' From 719e9b304291e496db73d7781c8aa0a279f7d84b Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sat, 24 May 2025 11:28:47 +0300 Subject: [PATCH 22/30] Fix a test in `test_updater.py` that hangs. --- tests/ext/test_updater.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ext/test_updater.py b/tests/ext/test_updater.py index 0ed37b6fadd..92a2d65ce7d 100644 --- a/tests/ext/test_updater.py +++ b/tests/ext/test_updater.py @@ -417,7 +417,7 @@ async def test_start_polling_get_updates_parameters(self, updater, monkeypatch): on_stop_flag = False expected = { - "timeout": 10, + "timeout": dtm.timedelta(seconds=10), "allowed_updates": None, "api_kwargs": None, } @@ -457,14 +457,14 @@ async def get_updates(*args, **kwargs): on_stop_flag = False expected = { - "timeout": 42, + "timeout": dtm.timedelta(seconds=42), "allowed_updates": ["message"], "api_kwargs": None, } await update_queue.put(Update(update_id=2)) await updater.start_polling( - timeout=42, + timeout=dtm.timedelta(seconds=42), allowed_updates=["message"], ) await update_queue.join() From 574b09d9242e2aa21b22d70d96f74485765ac58f Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Tue, 27 May 2025 02:33:33 +0300 Subject: [PATCH 23/30] Move `to_dict` logic to `_telegramobject.py`. --- src/telegram/_chatfullinfo.py | 14 ------ src/telegram/_chatinvitelink.py | 10 ---- src/telegram/_files/animation.py | 10 ---- src/telegram/_files/audio.py | 10 ---- src/telegram/_files/inputmedia.py | 23 --------- src/telegram/_files/location.py | 10 ---- src/telegram/_files/video.py | 12 ----- src/telegram/_files/videonote.py | 10 ---- src/telegram/_files/voice.py | 10 ---- .../_inline/inlinequeryresultaudio.py | 10 ---- src/telegram/_inline/inlinequeryresultgif.py | 10 ---- .../_inline/inlinequeryresultlocation.py | 10 ---- .../_inline/inlinequeryresultmpeg4gif.py | 10 ---- .../_inline/inlinequeryresultvideo.py | 10 ---- .../_inline/inlinequeryresultvoice.py | 10 ---- .../_inline/inputlocationmessagecontent.py | 10 ---- .../_messageautodeletetimerchanged.py | 10 ---- src/telegram/_paidmedia.py | 10 ---- src/telegram/_poll.py | 10 ---- src/telegram/_telegramobject.py | 48 +++++++++++++++++-- src/telegram/_videochat.py | 10 ---- 21 files changed, 45 insertions(+), 222 deletions(-) diff --git a/src/telegram/_chatfullinfo.py b/src/telegram/_chatfullinfo.py index 8db9b5468d1..4a313c296f6 100644 --- a/src/telegram/_chatfullinfo.py +++ b/src/telegram/_chatfullinfo.py @@ -655,17 +655,3 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo": ) return super().de_json(data=data, bot=bot) - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - - keys = ("slow_mode_delay", "message_auto_delete_time") - for key in keys: - if (value := getattr(self, "_" + key)) is not None: - seconds = value.total_seconds() - out[key] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out[key] = value - - return out diff --git a/src/telegram/_chatinvitelink.py b/src/telegram/_chatinvitelink.py index f11cc7bea90..920e43e85b8 100644 --- a/src/telegram/_chatinvitelink.py +++ b/src/telegram/_chatinvitelink.py @@ -206,13 +206,3 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatInviteLink ) return super().de_json(data=data, bot=bot) - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._subscription_period is not None: - seconds = self._subscription_period.total_seconds() - out["subscription_period"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["subscription_period"] = self._subscription_period - return out diff --git a/src/telegram/_files/animation.py b/src/telegram/_files/animation.py index 75bc4297fe8..89434a13723 100644 --- a/src/telegram/_files/animation.py +++ b/src/telegram/_files/animation.py @@ -128,13 +128,3 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Animation": data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None return super().de_json(data=data, bot=bot) - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._duration is not None: - seconds = self._duration.total_seconds() - out["duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["duration"] = self._duration - return out diff --git a/src/telegram/_files/audio.py b/src/telegram/_files/audio.py index 47cb467f322..c0b67b3d2c5 100644 --- a/src/telegram/_files/audio.py +++ b/src/telegram/_files/audio.py @@ -130,13 +130,3 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Audio": data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None return super().de_json(data=data, bot=bot) - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._duration is not None: - seconds = self._duration.total_seconds() - out["duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["duration"] = self._duration - return out diff --git a/src/telegram/_files/inputmedia.py b/src/telegram/_files/inputmedia.py index 39fc279afc1..eff69613387 100644 --- a/src/telegram/_files/inputmedia.py +++ b/src/telegram/_files/inputmedia.py @@ -112,19 +112,6 @@ def _parse_thumbnail_input(thumbnail: Optional[FileInput]) -> Optional[Union[str else thumbnail ) - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if isinstance(self, (InputMediaAnimation, InputMediaVideo, InputMediaAudio)): - if self._duration is not None: - seconds = self._duration.total_seconds() - # We *must* convert to int here because currently BOT API returns 'BadRequest' - # for float values - out["duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["duration"] = self._duration - return out - class InputPaidMedia(TelegramObject): """ @@ -310,16 +297,6 @@ def __init__( def duration(self) -> Optional[Union[int, dtm.timedelta]]: return get_timedelta_value(self._duration, attribute="duration") - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._duration is not None: - seconds = self._duration.total_seconds() - out["duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["duration"] = self._duration - return out - class InputMediaAnimation(InputMedia): """Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. diff --git a/src/telegram/_files/location.py b/src/telegram/_files/location.py index d657e24a368..fa6ee7de41c 100644 --- a/src/telegram/_files/location.py +++ b/src/telegram/_files/location.py @@ -123,16 +123,6 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Location": return super().de_json(data=data, bot=bot) - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._live_period is not None: - seconds = self._live_period.total_seconds() - out["live_period"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["live_period"] = self._live_period - return out - HORIZONTAL_ACCURACY: Final[int] = constants.LocationLimit.HORIZONTAL_ACCURACY """:const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY` diff --git a/src/telegram/_files/video.py b/src/telegram/_files/video.py index cc122be5127..357caddaf22 100644 --- a/src/telegram/_files/video.py +++ b/src/telegram/_files/video.py @@ -167,15 +167,3 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Video": data["cover"] = de_list_optional(data.get("cover"), PhotoSize, bot) return super().de_json(data=data, bot=bot) - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - keys = ("duration", "start_timestamp") - for key in keys: - if (value := getattr(self, "_" + key)) is not None: - seconds = value.total_seconds() - out[key] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out[key] = value - return out diff --git a/src/telegram/_files/videonote.py b/src/telegram/_files/videonote.py index ef5770cde4c..1b85fd4b875 100644 --- a/src/telegram/_files/videonote.py +++ b/src/telegram/_files/videonote.py @@ -116,13 +116,3 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "VideoNote": data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None return super().de_json(data=data, bot=bot) - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._duration is not None: - seconds = self._duration.total_seconds() - out["duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["duration"] = self._duration - return out diff --git a/src/telegram/_files/voice.py b/src/telegram/_files/voice.py index 07d125ba334..b0f2997fbdd 100644 --- a/src/telegram/_files/voice.py +++ b/src/telegram/_files/voice.py @@ -103,13 +103,3 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Voice": data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None return super().de_json(data=data, bot=bot) - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._duration is not None: - seconds = self._duration.total_seconds() - out["duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["duration"] = self._duration - return out diff --git a/src/telegram/_inline/inlinequeryresultaudio.py b/src/telegram/_inline/inlinequeryresultaudio.py index 600bedda378..cbfff47470b 100644 --- a/src/telegram/_inline/inlinequeryresultaudio.py +++ b/src/telegram/_inline/inlinequeryresultaudio.py @@ -142,13 +142,3 @@ def __init__( @property def audio_duration(self) -> Optional[Union[int, dtm.timedelta]]: return get_timedelta_value(self._audio_duration, attribute="audio_duration") - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._audio_duration is not None: - seconds = self._audio_duration.total_seconds() - out["audio_duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["audio_duration"] = self._audio_duration - return out diff --git a/src/telegram/_inline/inlinequeryresultgif.py b/src/telegram/_inline/inlinequeryresultgif.py index 494d338f0f1..fa2c59b03be 100644 --- a/src/telegram/_inline/inlinequeryresultgif.py +++ b/src/telegram/_inline/inlinequeryresultgif.py @@ -186,13 +186,3 @@ def __init__( @property def gif_duration(self) -> Optional[Union[int, dtm.timedelta]]: return get_timedelta_value(self._gif_duration, attribute="gif_duration") - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._gif_duration is not None: - seconds = self._gif_duration.total_seconds() - out["gif_duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["gif_duration"] = self._gif_duration - return out diff --git a/src/telegram/_inline/inlinequeryresultlocation.py b/src/telegram/_inline/inlinequeryresultlocation.py index 9c061722067..b8c41007f18 100644 --- a/src/telegram/_inline/inlinequeryresultlocation.py +++ b/src/telegram/_inline/inlinequeryresultlocation.py @@ -183,16 +183,6 @@ def __init__( def live_period(self) -> Optional[Union[int, dtm.timedelta]]: return get_timedelta_value(self._live_period, attribute="live_period") - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._live_period is not None: - seconds = self._live_period.total_seconds() - out["live_period"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["live_period"] = self._live_period - return out - HORIZONTAL_ACCURACY: Final[int] = constants.LocationLimit.HORIZONTAL_ACCURACY """:const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY` diff --git a/src/telegram/_inline/inlinequeryresultmpeg4gif.py b/src/telegram/_inline/inlinequeryresultmpeg4gif.py index e1d7ea08537..fb885d0409c 100644 --- a/src/telegram/_inline/inlinequeryresultmpeg4gif.py +++ b/src/telegram/_inline/inlinequeryresultmpeg4gif.py @@ -188,13 +188,3 @@ def __init__( @property def mpeg4_duration(self) -> Optional[Union[int, dtm.timedelta]]: return get_timedelta_value(self._mpeg4_duration, attribute="mpeg4_duration") - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._mpeg4_duration is not None: - seconds = self._mpeg4_duration.total_seconds() - out["mpeg4_duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["mpeg4_duration"] = self._mpeg4_duration - return out diff --git a/src/telegram/_inline/inlinequeryresultvideo.py b/src/telegram/_inline/inlinequeryresultvideo.py index 846b63d4167..831ba6e4748 100644 --- a/src/telegram/_inline/inlinequeryresultvideo.py +++ b/src/telegram/_inline/inlinequeryresultvideo.py @@ -194,13 +194,3 @@ def __init__( @property def video_duration(self) -> Optional[Union[int, dtm.timedelta]]: return get_timedelta_value(self._video_duration, attribute="video_duration") - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._video_duration is not None: - seconds = self._video_duration.total_seconds() - out["video_duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["video_duration"] = self._video_duration - return out diff --git a/src/telegram/_inline/inlinequeryresultvoice.py b/src/telegram/_inline/inlinequeryresultvoice.py index a0d7511d751..5b5b0f42ec8 100644 --- a/src/telegram/_inline/inlinequeryresultvoice.py +++ b/src/telegram/_inline/inlinequeryresultvoice.py @@ -139,13 +139,3 @@ def __init__( @property def voice_duration(self) -> Optional[Union[int, dtm.timedelta]]: return get_timedelta_value(self._voice_duration, attribute="voice_duration") - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._voice_duration is not None: - seconds = self._voice_duration.total_seconds() - out["voice_duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["voice_duration"] = self._voice_duration - return out diff --git a/src/telegram/_inline/inputlocationmessagecontent.py b/src/telegram/_inline/inputlocationmessagecontent.py index 6d6a71f0637..2c7228977c4 100644 --- a/src/telegram/_inline/inputlocationmessagecontent.py +++ b/src/telegram/_inline/inputlocationmessagecontent.py @@ -127,16 +127,6 @@ def __init__( def live_period(self) -> Optional[Union[int, dtm.timedelta]]: return get_timedelta_value(self._live_period, attribute="live_period") - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._live_period is not None: - seconds = self._live_period.total_seconds() - out["live_period"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["live_period"] = self._live_period - return out - HORIZONTAL_ACCURACY: Final[int] = constants.LocationLimit.HORIZONTAL_ACCURACY """:const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY` diff --git a/src/telegram/_messageautodeletetimerchanged.py b/src/telegram/_messageautodeletetimerchanged.py index a636cc9a9e5..0fc0eb78c6a 100644 --- a/src/telegram/_messageautodeletetimerchanged.py +++ b/src/telegram/_messageautodeletetimerchanged.py @@ -90,13 +90,3 @@ def de_json( ) return super().de_json(data=data, bot=bot) - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._message_auto_delete_time is not None: - seconds = self._message_auto_delete_time.total_seconds() - out["message_auto_delete_time"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["message_auto_delete_time"] = self._message_auto_delete_time - return out diff --git a/src/telegram/_paidmedia.py b/src/telegram/_paidmedia.py index ec2afd6aaff..eac8b730373 100644 --- a/src/telegram/_paidmedia.py +++ b/src/telegram/_paidmedia.py @@ -168,16 +168,6 @@ def __init__( def duration(self) -> Optional[Union[int, dtm.timedelta]]: return get_timedelta_value(self._duration, attribute="duration") - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._duration is not None: - seconds = self._duration.total_seconds() - out["duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["duration"] = self._duration - return out - class PaidMediaPhoto(PaidMedia): """ diff --git a/src/telegram/_poll.py b/src/telegram/_poll.py index 22dffa52edb..4784febd16c 100644 --- a/src/telegram/_poll.py +++ b/src/telegram/_poll.py @@ -497,16 +497,6 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Poll": return super().de_json(data=data, bot=bot) - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._open_period is not None: - seconds = self._open_period.total_seconds() - out["open_period"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["open_period"] = self._open_period - return out - def parse_explanation_entity(self, entity: MessageEntity) -> str: """Returns the text in :attr:`explanation` from a given :class:`telegram.MessageEntity` of :attr:`explanation_entities`. diff --git a/src/telegram/_telegramobject.py b/src/telegram/_telegramobject.py index ca0d20555eb..aabedeb9f05 100644 --- a/src/telegram/_telegramobject.py +++ b/src/telegram/_telegramobject.py @@ -499,6 +499,13 @@ def _apply_api_kwargs(self, api_kwargs: JSONDict) -> None: elif getattr(self, key, True) is None: setattr(self, key, api_kwargs.pop(key)) + def _is_deprecated_attr(self, attr: str) -> bool: + """Checks wheather `attr` is in the list of deprecated time period attributes.""" + return ( + (class_name := self.__class__.__name__) in TelegramObject._TIME_PERIOD_DEPRECATIONS + and attr in TelegramObject._TIME_PERIOD_DEPRECATIONS[class_name] + ) + def _get_attrs_names(self, include_private: bool) -> Iterator[str]: """ Returns the names of the attributes of this object. This is used to determine which @@ -521,7 +528,11 @@ def _get_attrs_names(self, include_private: bool) -> Iterator[str]: if include_private: return all_attrs - return (attr for attr in all_attrs if not attr.startswith("_")) + return ( + attr + for attr in all_attrs + if not attr.startswith("_") or self._is_deprecated_attr(attr) + ) def _get_attrs( self, @@ -558,7 +569,7 @@ def _get_attrs( if recursive and hasattr(value, "to_dict"): data[key] = value.to_dict(recursive=True) else: - data[key] = value + data[key.removeprefix("_") if self._is_deprecated_attr(key) else key] = value elif not recursive: data[key] = value @@ -629,7 +640,11 @@ def to_dict(self, recursive: bool = True) -> JSONDict: elif isinstance(value, dtm.datetime): out[key] = to_timestamp(value) elif isinstance(value, dtm.timedelta): - out[key] = value.total_seconds() + # Converting to int here is neccassry in some cases where Bot API returns + # 'BadRquest' when expecting integers (e.g. Video.duration) + out[key] = ( + int(seconds) if (seconds := value.total_seconds()).is_integer() else seconds + ) for key in pop_keys: out.pop(key) @@ -665,3 +680,30 @@ def set_bot(self, bot: Optional["Bot"]) -> None: bot (:class:`telegram.Bot` | :obj:`None`): The bot instance. """ self._bot = bot + + # We use str keys to avoid importing which causes circular dependencies + _TIME_PERIOD_DEPRECATIONS: ClassVar = { + "ChatFullInfo": ("_message_auto_delete_time", "_slow_mode_delay"), + "Animation": ("_duration",), + "Audio": ("_duration",), + "Video": ("_duration", "_start_timestamp"), + "VideoNote": ("_duration",), + "Voice": ("_duration",), + "PaidMediaPreview": ("_duration",), + "VideoChatEnded": ("_duration",), + "InputMediaVideo": ("_duration",), + "InputMediaAnimation": ("_duration",), + "InputMediaAudio": ("_duration",), + "InputPaidMediaVideo": ("_duration",), + "InlineQueryResultGif": ("_gif_duration",), + "InlineQueryResultMpeg4Gif": ("_mpeg4_duration",), + "InlineQueryResultVideo": ("_video_duration",), + "InlineQueryResultAudio": ("_audio_duration",), + "InlineQueryResultVoice": ("_voice_duration",), + "InlineQueryResultLocation": ("_live_period",), + "Poll": ("_open_period",), + "Location": ("_live_period",), + "MessageAutoDeleteTimerChanged": ("_message_auto_delete_time",), + "ChatInviteLink": ("_subscription_period",), + "InputLocationMessageContent": ("_live_period",), + } diff --git a/src/telegram/_videochat.py b/src/telegram/_videochat.py index c369e2d93f2..bf5b4ee3593 100644 --- a/src/telegram/_videochat.py +++ b/src/telegram/_videochat.py @@ -114,16 +114,6 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "VideoChatEnded return super().de_json(data=data, bot=bot) - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict`.""" - out = super().to_dict(recursive) - if self._duration is not None: - seconds = self._duration.total_seconds() - out["duration"] = int(seconds) if seconds.is_integer() else seconds - elif not recursive: - out["duration"] = self._duration - return out - class VideoChatParticipantsInvited(TelegramObject): """ From eb34ae3088da66565d24d9f5555cd175c5f73733 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Thu, 29 May 2025 01:40:34 +0300 Subject: [PATCH 24/30] Add a test for `get_timedelta_value`. --- tests/_utils/test_datetime.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/_utils/test_datetime.py b/tests/_utils/test_datetime.py index dfcaca67587..8628f0c109f 100644 --- a/tests/_utils/test_datetime.py +++ b/tests/_utils/test_datetime.py @@ -192,3 +192,20 @@ def test_extract_tzinfo_from_defaults(self, tz_bot, bot, raw_bot): assert tg_dtm.extract_tzinfo_from_defaults(tz_bot) == tz_bot.defaults.tzinfo assert tg_dtm.extract_tzinfo_from_defaults(bot) is None assert tg_dtm.extract_tzinfo_from_defaults(raw_bot) is None + + @pytest.mark.parametrize( + ("arg", "timedelta_result", "number_result"), + [ + (None, None, None), + (dtm.timedelta(seconds=10), dtm.timedelta(seconds=10), 10), + (dtm.timedelta(seconds=10.5), dtm.timedelta(seconds=10.5), 10.5), + ], + ) + def test_get_timedelta_value(self, PTB_TIMEDELTA, arg, timedelta_result, number_result): + result = tg_dtm.get_timedelta_value(arg, attribute="") + + if PTB_TIMEDELTA: + assert result == timedelta_result + else: + assert result == number_result + assert type(result) is type(number_result) From 7d93eb5ad246722c498e4ffccae801ae84f3a9ea Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sun, 1 Jun 2025 18:14:42 +0300 Subject: [PATCH 25/30] Fix precommit. Failure at `src/telegram/_utils/datetime.py:38:0` is not related to this PR. Not sure why it's being reported just now (CI only). It's not caught locally (with fresh precommit cache). --- src/telegram/_payment/stars/staramount.py | 1 - src/telegram/_utils/datetime.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/telegram/_payment/stars/staramount.py b/src/telegram/_payment/stars/staramount.py index a8d61b2a118..c78a4aa9aba 100644 --- a/src/telegram/_payment/stars/staramount.py +++ b/src/telegram/_payment/stars/staramount.py @@ -16,7 +16,6 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -# pylint: disable=redefined-builtin """This module contains an object that represents a Telegram StarAmount.""" diff --git a/src/telegram/_utils/datetime.py b/src/telegram/_utils/datetime.py index 891e3481086..f3bf2abec43 100644 --- a/src/telegram/_utils/datetime.py +++ b/src/telegram/_utils/datetime.py @@ -35,7 +35,6 @@ from telegram._utils.warnings import warn from telegram.warnings import PTBDeprecationWarning -from tests.auxil.envvars import env_var_2_bool if TYPE_CHECKING: from telegram import Bot @@ -257,7 +256,7 @@ def get_timedelta_value( """ if value is None: return None - if env_var_2_bool(os.getenv("PTB_TIMEDELTA")): + if os.getenv("PTB_TIMEDELTA", "false").lower().strip() == "true": return value warn( PTBDeprecationWarning( From aba92dfa1a46549a5c2553c79a4fd66362fae9cc Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Tue, 10 Jun 2025 01:50:36 +0300 Subject: [PATCH 26/30] review: address comments. --- .../4750.jJBu7iAgZa96hdqcpHK96W.toml | 6 ++-- docs/substitutions/global.rst | 2 +- src/telegram/_chatfullinfo.py | 13 ++------- src/telegram/_chatinvitelink.py | 7 ++--- src/telegram/_files/_inputstorycontent.py | 6 ++-- src/telegram/_files/animation.py | 17 ++--------- src/telegram/_files/audio.py | 17 ++--------- src/telegram/_files/inputmedia.py | 10 +++---- src/telegram/_files/inputprofilephoto.py | 8 ++---- src/telegram/_files/location.py | 18 ++---------- src/telegram/_files/video.py | 10 ++----- src/telegram/_files/videonote.py | 17 ++--------- src/telegram/_files/voice.py | 18 ++---------- .../_inline/inlinequeryresultaudio.py | 4 +-- src/telegram/_inline/inlinequeryresultgif.py | 4 +-- .../_inline/inlinequeryresultlocation.py | 4 +-- .../_inline/inlinequeryresultmpeg4gif.py | 4 +-- .../_inline/inlinequeryresultvideo.py | 4 +-- .../_inline/inlinequeryresultvoice.py | 4 +-- .../_inline/inputlocationmessagecontent.py | 4 +-- .../_messageautodeletetimerchanged.py | 21 ++------------ src/telegram/_paidmedia.py | 4 +-- .../_payment/stars/transactionpartner.py | 28 +++++++++++-------- src/telegram/_poll.py | 5 ++-- src/telegram/_telegramobject.py | 1 + src/telegram/_utils/argumentparsing.py | 2 +- src/telegram/_utils/datetime.py | 6 ++-- src/telegram/_videochat.py | 13 ++------- src/telegram/error.py | 7 ++--- src/telegram/ext/_application.py | 3 +- tests/_files/test_video.py | 12 +++++--- tests/auxil/envvars.py | 2 +- tests/conftest.py | 2 +- tests/test_official/arg_type_checker.py | 6 ++-- tests/test_official/exceptions.py | 3 -- 35 files changed, 100 insertions(+), 192 deletions(-) diff --git a/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml b/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml index ae225ae88d3..5d9d75d7ca9 100644 --- a/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml +++ b/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml @@ -1,7 +1,7 @@ -features = "Use `timedelta` to represent time periods in classes" -deprecations = """In this release, we're migrating attributes of Telegram objects that represent durations/time periods from having :obj:`int` type to Python's native :class:`datetime.timedelta`. +features = "Use `timedelta` to represent time periods in class arguments and attributes" +deprecations = """In this release, we're migrating attributes of Telegram objects that represent durations/time periods from having :obj:`int` type to Python's native :class:`datetime.timedelta`. This change is opt-in for now to allow for a smooth transition phase. It will become opt-out in future releases. -Set ``PTB_TIMEDELTA=true`` as an environment variable to make these attributes return :obj:`datetime.timedelta` objects instead of integers. Support for :obj:`int` values is deprecated and will be removed in a future major version. +Set ``PTB_TIMEDELTA=true`` or ``PTB_TIMEDELTA=1`` as an environment variable to make these attributes return :obj:`datetime.timedelta` objects instead of integers. Support for :obj:`int` values is deprecated and will be removed in a future major version. Affected Attributes: - :attr:`telegram.ChatFullInfo.slow_mode_delay` and :attr:`telegram.ChatFullInfo.message_auto_delete_time` diff --git a/docs/substitutions/global.rst b/docs/substitutions/global.rst index 88cf095caa3..ed4b40ecdee 100644 --- a/docs/substitutions/global.rst +++ b/docs/substitutions/global.rst @@ -102,4 +102,4 @@ .. |time-period-input| replace:: :class:`datetime.timedelta` objects are accepted in addition to plain :obj:`int` values. -.. |time-period-int-deprecated| replace:: In a future major version this attribute will be of type :obj:`datetime.timedelta`. You can opt-in early by setting `PTB_TIMEDELTA=true` as an environment variable. +.. |time-period-int-deprecated| replace:: In a future major version this attribute will be of type :obj:`datetime.timedelta`. You can opt-in early by setting `PTB_TIMEDELTA=true` or ``PTB_TIMEDELTA=1`` as an environment variable. diff --git a/src/telegram/_chatfullinfo.py b/src/telegram/_chatfullinfo.py index 4a313c296f6..7d0a5838063 100644 --- a/src/telegram/_chatfullinfo.py +++ b/src/telegram/_chatfullinfo.py @@ -32,8 +32,8 @@ from telegram._utils.argumentparsing import ( de_json_optional, de_list_optional, - parse_period_arg, parse_sequence_arg, + to_timedelta, ) from telegram._utils.datetime import ( extract_tzinfo_from_defaults, @@ -534,8 +534,8 @@ def __init__( self.invite_link: Optional[str] = invite_link self.pinned_message: Optional[Message] = pinned_message self.permissions: Optional[ChatPermissions] = permissions - self._slow_mode_delay: Optional[dtm.timedelta] = parse_period_arg(slow_mode_delay) - self._message_auto_delete_time: Optional[dtm.timedelta] = parse_period_arg( + self._slow_mode_delay: Optional[dtm.timedelta] = to_timedelta(slow_mode_delay) + self._message_auto_delete_time: Optional[dtm.timedelta] = to_timedelta( message_auto_delete_time ) self.has_protected_content: Optional[bool] = has_protected_content @@ -631,13 +631,6 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo": Message, ) - data["slow_mode_delay"] = ( - dtm.timedelta(seconds=s) if (s := data.get("slow_mode_delay")) else None - ) - data["message_auto_delete_time"] = ( - dtm.timedelta(seconds=s) if (s := data.get("message_auto_delete_time")) else None - ) - data["pinned_message"] = de_json_optional(data.get("pinned_message"), Message, bot) data["permissions"] = de_json_optional(data.get("permissions"), ChatPermissions, bot) data["location"] = de_json_optional(data.get("location"), ChatLocation, bot) diff --git a/src/telegram/_chatinvitelink.py b/src/telegram/_chatinvitelink.py index 920e43e85b8..dc5486924e6 100644 --- a/src/telegram/_chatinvitelink.py +++ b/src/telegram/_chatinvitelink.py @@ -22,7 +22,7 @@ from telegram._telegramobject import TelegramObject from telegram._user import User -from telegram._utils.argumentparsing import de_json_optional, parse_period_arg +from telegram._utils.argumentparsing import de_json_optional, to_timedelta from telegram._utils.datetime import ( extract_tzinfo_from_defaults, from_timestamp, @@ -174,7 +174,7 @@ def __init__( self.pending_join_request_count: Optional[int] = ( int(pending_join_request_count) if pending_join_request_count is not None else None ) - self._subscription_period: Optional[dtm.timedelta] = parse_period_arg(subscription_period) + self._subscription_period: Optional[dtm.timedelta] = to_timedelta(subscription_period) self.subscription_price: Optional[int] = subscription_price self._id_attrs = ( @@ -201,8 +201,5 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatInviteLink data["creator"] = de_json_optional(data.get("creator"), User, bot) data["expire_date"] = from_timestamp(data.get("expire_date", None), tzinfo=loc_tzinfo) - data["subscription_period"] = ( - dtm.timedelta(seconds=s) if (s := data.get("subscription_period")) else None - ) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_files/_inputstorycontent.py b/src/telegram/_files/_inputstorycontent.py index 4481ecf9814..dd8f25c5810 100644 --- a/src/telegram/_files/_inputstorycontent.py +++ b/src/telegram/_files/_inputstorycontent.py @@ -25,7 +25,7 @@ from telegram._files.inputfile import InputFile from telegram._telegramobject import TelegramObject from telegram._utils import enum -from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.argumentparsing import to_timedelta from telegram._utils.files import parse_file_input from telegram._utils.types import FileInput, JSONDict @@ -159,8 +159,8 @@ def __init__( with self._unfrozen(): self.video: Union[str, InputFile] = self._parse_file_input(video) - self.duration: Optional[dtm.timedelta] = parse_period_arg(duration) - self.cover_frame_timestamp: Optional[dtm.timedelta] = parse_period_arg( + self.duration: Optional[dtm.timedelta] = to_timedelta(duration) + self.cover_frame_timestamp: Optional[dtm.timedelta] = to_timedelta( cover_frame_timestamp ) self.is_animation: Optional[bool] = is_animation diff --git a/src/telegram/_files/animation.py b/src/telegram/_files/animation.py index 89434a13723..90d4a8c7e57 100644 --- a/src/telegram/_files/animation.py +++ b/src/telegram/_files/animation.py @@ -18,17 +18,14 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Animation.""" import datetime as dtm -from typing import TYPE_CHECKING, Optional, Union +from typing import Optional, Union from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.argumentparsing import to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.types import JSONDict, TimePeriod -if TYPE_CHECKING: - from telegram import Bot - class Animation(_BaseThumbedMedium): """This object represents an animation file (GIF or H.264/MPEG-4 AVC video without sound). @@ -110,7 +107,7 @@ def __init__( # Required self.width: int = width self.height: int = height - self._duration: dtm.timedelta = parse_period_arg(duration) # type: ignore[assignment] + self._duration: dtm.timedelta = to_timedelta(duration) # type: ignore[assignment] # Optional self.mime_type: Optional[str] = mime_type self.file_name: Optional[str] = file_name @@ -120,11 +117,3 @@ def duration(self) -> Union[int, dtm.timedelta]: return get_timedelta_value( # type: ignore[return-value] self._duration, attribute="duration" ) - - @classmethod - def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Animation": - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None - - return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_files/audio.py b/src/telegram/_files/audio.py index c0b67b3d2c5..f408d6b5845 100644 --- a/src/telegram/_files/audio.py +++ b/src/telegram/_files/audio.py @@ -18,17 +18,14 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Audio.""" import datetime as dtm -from typing import TYPE_CHECKING, Optional, Union +from typing import Optional, Union from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.argumentparsing import to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.types import JSONDict, TimePeriod -if TYPE_CHECKING: - from telegram import Bot - class Audio(_BaseThumbedMedium): """This object represents an audio file to be treated as music by the Telegram clients. @@ -110,7 +107,7 @@ def __init__( ) with self._unfrozen(): # Required - self._duration: dtm.timedelta = parse_period_arg(duration) # type: ignore[assignment] + self._duration: dtm.timedelta = to_timedelta(duration) # type: ignore[assignment] # Optional self.performer: Optional[str] = performer self.title: Optional[str] = title @@ -122,11 +119,3 @@ def duration(self) -> Union[int, dtm.timedelta]: return get_timedelta_value( # type: ignore[return-value] self._duration, attribute="duration" ) - - @classmethod - def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Audio": - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None - - return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_files/inputmedia.py b/src/telegram/_files/inputmedia.py index eff69613387..5746fd5b1ba 100644 --- a/src/telegram/_files/inputmedia.py +++ b/src/telegram/_files/inputmedia.py @@ -31,7 +31,7 @@ from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject from telegram._utils import enum -from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.files import parse_file_input @@ -286,7 +286,7 @@ def __init__( ) self.width: Optional[int] = width self.height: Optional[int] = height - self._duration: Optional[dtm.timedelta] = parse_period_arg(duration) + self._duration: Optional[dtm.timedelta] = to_timedelta(duration) self.supports_streaming: Optional[bool] = supports_streaming self.cover: Optional[Union[InputFile, str]] = ( parse_file_input(cover, attach=True, local_mode=True) if cover else None @@ -432,7 +432,7 @@ def __init__( ) self.width: Optional[int] = width self.height: Optional[int] = height - self._duration: Optional[dtm.timedelta] = parse_period_arg(duration) + self._duration: Optional[dtm.timedelta] = to_timedelta(duration) self.has_spoiler: Optional[bool] = has_spoiler self.show_caption_above_media: Optional[bool] = show_caption_above_media @@ -686,7 +686,7 @@ def __init__( with self._unfrozen(): self.width: Optional[int] = width self.height: Optional[int] = height - self._duration: Optional[dtm.timedelta] = parse_period_arg(duration) + self._duration: Optional[dtm.timedelta] = to_timedelta(duration) self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumbnail_input( thumbnail ) @@ -815,7 +815,7 @@ def __init__( self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumbnail_input( thumbnail ) - self._duration: Optional[dtm.timedelta] = parse_period_arg(duration) + self._duration: Optional[dtm.timedelta] = to_timedelta(duration) self.title: Optional[str] = title self.performer: Optional[str] = performer diff --git a/src/telegram/_files/inputprofilephoto.py b/src/telegram/_files/inputprofilephoto.py index 8ec1ae93492..5a37ab6af80 100644 --- a/src/telegram/_files/inputprofilephoto.py +++ b/src/telegram/_files/inputprofilephoto.py @@ -24,6 +24,7 @@ from telegram import constants from telegram._telegramobject import TelegramObject from telegram._utils import enum +from telegram._utils.argumentparsing import to_timedelta from telegram._utils.files import parse_file_input from telegram._utils.types import FileInput, JSONDict @@ -134,9 +135,4 @@ def __init__( animation, attach=True, local_mode=True ) - if isinstance(main_frame_timestamp, dtm.timedelta): - self.main_frame_timestamp: Optional[dtm.timedelta] = main_frame_timestamp - elif main_frame_timestamp is None: - self.main_frame_timestamp = None - else: - self.main_frame_timestamp = dtm.timedelta(seconds=main_frame_timestamp) + self.main_frame_timestamp: Optional[dtm.timedelta] = to_timedelta(main_frame_timestamp) diff --git a/src/telegram/_files/location.py b/src/telegram/_files/location.py index fa6ee7de41c..97e8da68993 100644 --- a/src/telegram/_files/location.py +++ b/src/telegram/_files/location.py @@ -19,17 +19,14 @@ """This module contains an object that represents a Telegram Location.""" import datetime as dtm -from typing import TYPE_CHECKING, Final, Optional, Union +from typing import Final, Optional, Union from telegram import constants from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.argumentparsing import to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.types import JSONDict, TimePeriod -if TYPE_CHECKING: - from telegram import Bot - class Location(TelegramObject): """This object represents a point on the map. @@ -100,7 +97,7 @@ def __init__( # Optionals self.horizontal_accuracy: Optional[float] = horizontal_accuracy - self._live_period: Optional[dtm.timedelta] = parse_period_arg(live_period) + self._live_period: Optional[dtm.timedelta] = to_timedelta(live_period) self.heading: Optional[int] = heading self.proximity_alert_radius: Optional[int] = ( int(proximity_alert_radius) if proximity_alert_radius else None @@ -114,15 +111,6 @@ def __init__( def live_period(self) -> Optional[Union[int, dtm.timedelta]]: return get_timedelta_value(self._live_period, attribute="live_period") - @classmethod - def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Location": - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - - data["live_period"] = dtm.timedelta(seconds=s) if (s := data.get("live_period")) else None - - return super().de_json(data=data, bot=bot) - HORIZONTAL_ACCURACY: Final[int] = constants.LocationLimit.HORIZONTAL_ACCURACY """:const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY` diff --git a/src/telegram/_files/video.py b/src/telegram/_files/video.py index 357caddaf22..7998879be3f 100644 --- a/src/telegram/_files/video.py +++ b/src/telegram/_files/video.py @@ -23,7 +23,7 @@ from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.argumentparsing import de_list_optional, parse_period_arg, parse_sequence_arg +from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg, to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.types import JSONDict, TimePeriod @@ -138,12 +138,12 @@ def __init__( # Required self.width: int = width self.height: int = height - self._duration: dtm.timedelta = parse_period_arg(duration) # type: ignore[assignment] + self._duration: dtm.timedelta = to_timedelta(duration) # type: ignore[assignment] # Optional self.mime_type: Optional[str] = mime_type self.file_name: Optional[str] = file_name self.cover: Optional[Sequence[PhotoSize]] = parse_sequence_arg(cover) - self._start_timestamp: Optional[dtm.timedelta] = parse_period_arg(start_timestamp) + self._start_timestamp: Optional[dtm.timedelta] = to_timedelta(start_timestamp) @property def duration(self) -> Union[int, dtm.timedelta]: @@ -160,10 +160,6 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Video": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None - data["start_timestamp"] = ( - dtm.timedelta(seconds=s) if (s := data.get("start_timestamp")) else None - ) data["cover"] = de_list_optional(data.get("cover"), PhotoSize, bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_files/videonote.py b/src/telegram/_files/videonote.py index 1b85fd4b875..0997980ad9c 100644 --- a/src/telegram/_files/videonote.py +++ b/src/telegram/_files/videonote.py @@ -19,17 +19,14 @@ """This module contains an object that represents a Telegram VideoNote.""" import datetime as dtm -from typing import TYPE_CHECKING, Optional, Union +from typing import Optional, Union from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.argumentparsing import to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.types import JSONDict, TimePeriod -if TYPE_CHECKING: - from telegram import Bot - class VideoNote(_BaseThumbedMedium): """This object represents a video message (available in Telegram apps as of v.4.0). @@ -101,18 +98,10 @@ def __init__( with self._unfrozen(): # Required self.length: int = length - self._duration: dtm.timedelta = parse_period_arg(duration) # type: ignore[assignment] + self._duration: dtm.timedelta = to_timedelta(duration) # type: ignore[assignment] @property def duration(self) -> Union[int, dtm.timedelta]: return get_timedelta_value( # type: ignore[return-value] self._duration, attribute="duration" ) - - @classmethod - def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "VideoNote": - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None - - return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_files/voice.py b/src/telegram/_files/voice.py index b0f2997fbdd..c8528fd1728 100644 --- a/src/telegram/_files/voice.py +++ b/src/telegram/_files/voice.py @@ -18,16 +18,13 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Voice.""" import datetime as dtm -from typing import TYPE_CHECKING, Optional, Union +from typing import Optional, Union from telegram._files._basemedium import _BaseMedium -from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.argumentparsing import to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.types import JSONDict, TimePeriod -if TYPE_CHECKING: - from telegram import Bot - class Voice(_BaseMedium): """This object represents a voice note. @@ -85,7 +82,7 @@ def __init__( ) with self._unfrozen(): # Required - self._duration: dtm.timedelta = parse_period_arg(duration) # type: ignore[assignment] + self._duration: dtm.timedelta = to_timedelta(duration) # type: ignore[assignment] # Optional self.mime_type: Optional[str] = mime_type @@ -94,12 +91,3 @@ def duration(self) -> Union[int, dtm.timedelta]: return get_timedelta_value( # type: ignore[return-value] self._duration, attribute="duration" ) - - @classmethod - def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Voice": - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - - data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None - - return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_inline/inlinequeryresultaudio.py b/src/telegram/_inline/inlinequeryresultaudio.py index cbfff47470b..92b4ae81445 100644 --- a/src/telegram/_inline/inlinequeryresultaudio.py +++ b/src/telegram/_inline/inlinequeryresultaudio.py @@ -24,7 +24,7 @@ from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput, TimePeriod @@ -132,7 +132,7 @@ def __init__( # Optionals self.performer: Optional[str] = performer - self._audio_duration: Optional[dtm.timedelta] = parse_period_arg(audio_duration) + self._audio_duration: Optional[dtm.timedelta] = to_timedelta(audio_duration) self.caption: Optional[str] = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) diff --git a/src/telegram/_inline/inlinequeryresultgif.py b/src/telegram/_inline/inlinequeryresultgif.py index fa2c59b03be..4ead8759989 100644 --- a/src/telegram/_inline/inlinequeryresultgif.py +++ b/src/telegram/_inline/inlinequeryresultgif.py @@ -24,7 +24,7 @@ from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput, TimePeriod @@ -173,7 +173,7 @@ def __init__( # Optionals self.gif_width: Optional[int] = gif_width self.gif_height: Optional[int] = gif_height - self._gif_duration: Optional[dtm.timedelta] = parse_period_arg(gif_duration) + self._gif_duration: Optional[dtm.timedelta] = to_timedelta(gif_duration) self.title: Optional[str] = title self.caption: Optional[str] = caption self.parse_mode: ODVInput[str] = parse_mode diff --git a/src/telegram/_inline/inlinequeryresultlocation.py b/src/telegram/_inline/inlinequeryresultlocation.py index b8c41007f18..bbe222157bc 100644 --- a/src/telegram/_inline/inlinequeryresultlocation.py +++ b/src/telegram/_inline/inlinequeryresultlocation.py @@ -24,7 +24,7 @@ from telegram import constants from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult -from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.argumentparsing import to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.types import JSONDict, TimePeriod @@ -167,7 +167,7 @@ def __init__( self.title: str = title # Optionals - self._live_period: Optional[dtm.timedelta] = parse_period_arg(live_period) + self._live_period: Optional[dtm.timedelta] = to_timedelta(live_period) self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup self.input_message_content: Optional[InputMessageContent] = input_message_content self.thumbnail_url: Optional[str] = thumbnail_url diff --git a/src/telegram/_inline/inlinequeryresultmpeg4gif.py b/src/telegram/_inline/inlinequeryresultmpeg4gif.py index fb885d0409c..4a521642d01 100644 --- a/src/telegram/_inline/inlinequeryresultmpeg4gif.py +++ b/src/telegram/_inline/inlinequeryresultmpeg4gif.py @@ -24,7 +24,7 @@ from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput, TimePeriod @@ -175,7 +175,7 @@ def __init__( # Optional self.mpeg4_width: Optional[int] = mpeg4_width self.mpeg4_height: Optional[int] = mpeg4_height - self._mpeg4_duration: Optional[dtm.timedelta] = parse_period_arg(mpeg4_duration) + self._mpeg4_duration: Optional[dtm.timedelta] = to_timedelta(mpeg4_duration) self.title: Optional[str] = title self.caption: Optional[str] = caption self.parse_mode: ODVInput[str] = parse_mode diff --git a/src/telegram/_inline/inlinequeryresultvideo.py b/src/telegram/_inline/inlinequeryresultvideo.py index 831ba6e4748..5b98aa00557 100644 --- a/src/telegram/_inline/inlinequeryresultvideo.py +++ b/src/telegram/_inline/inlinequeryresultvideo.py @@ -24,7 +24,7 @@ from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput, TimePeriod @@ -185,7 +185,7 @@ def __init__( self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) self.video_width: Optional[int] = video_width self.video_height: Optional[int] = video_height - self._video_duration: Optional[dtm.timedelta] = parse_period_arg(video_duration) + self._video_duration: Optional[dtm.timedelta] = to_timedelta(video_duration) self.description: Optional[str] = description self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup self.input_message_content: Optional[InputMessageContent] = input_message_content diff --git a/src/telegram/_inline/inlinequeryresultvoice.py b/src/telegram/_inline/inlinequeryresultvoice.py index 5b5b0f42ec8..9dfcd0b94e0 100644 --- a/src/telegram/_inline/inlinequeryresultvoice.py +++ b/src/telegram/_inline/inlinequeryresultvoice.py @@ -24,7 +24,7 @@ from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput, TimePeriod @@ -129,7 +129,7 @@ def __init__( self.title: str = title # Optional - self._voice_duration: Optional[dtm.timedelta] = parse_period_arg(voice_duration) + self._voice_duration: Optional[dtm.timedelta] = to_timedelta(voice_duration) self.caption: Optional[str] = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) diff --git a/src/telegram/_inline/inputlocationmessagecontent.py b/src/telegram/_inline/inputlocationmessagecontent.py index 2c7228977c4..94ea4e2d893 100644 --- a/src/telegram/_inline/inputlocationmessagecontent.py +++ b/src/telegram/_inline/inputlocationmessagecontent.py @@ -23,7 +23,7 @@ from telegram import constants from telegram._inline.inputmessagecontent import InputMessageContent -from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.argumentparsing import to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.types import JSONDict, TimePeriod @@ -114,7 +114,7 @@ def __init__( self.longitude: float = longitude # Optionals - self._live_period: Optional[dtm.timedelta] = parse_period_arg(live_period) + self._live_period: Optional[dtm.timedelta] = to_timedelta(live_period) self.horizontal_accuracy: Optional[float] = horizontal_accuracy self.heading: Optional[int] = heading self.proximity_alert_radius: Optional[int] = ( diff --git a/src/telegram/_messageautodeletetimerchanged.py b/src/telegram/_messageautodeletetimerchanged.py index 0fc0eb78c6a..06b0a0ca49f 100644 --- a/src/telegram/_messageautodeletetimerchanged.py +++ b/src/telegram/_messageautodeletetimerchanged.py @@ -21,16 +21,13 @@ """ import datetime as dtm -from typing import TYPE_CHECKING, Optional, Union +from typing import Optional, Union from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.argumentparsing import to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.types import JSONDict, TimePeriod -if TYPE_CHECKING: - from telegram import Bot - class MessageAutoDeleteTimerChanged(TelegramObject): """This object represents a service message about a change in auto-delete timer settings. @@ -65,7 +62,7 @@ def __init__( api_kwargs: Optional[JSONDict] = None, ): super().__init__(api_kwargs=api_kwargs) - self._message_auto_delete_time: dtm.timedelta = parse_period_arg( + self._message_auto_delete_time: dtm.timedelta = to_timedelta( message_auto_delete_time ) # type: ignore[assignment] @@ -78,15 +75,3 @@ def message_auto_delete_time(self) -> Union[int, dtm.timedelta]: return get_timedelta_value( # type: ignore[return-value] self._message_auto_delete_time, attribute="message_auto_delete_time" ) - - @classmethod - def de_json( - cls, data: JSONDict, bot: Optional["Bot"] = None - ) -> "MessageAutoDeleteTimerChanged": - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - data["message_auto_delete_time"] = ( - dtm.timedelta(seconds=s) if (s := data.get("message_auto_delete_time")) else None - ) - - return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_paidmedia.py b/src/telegram/_paidmedia.py index eac8b730373..fe8ace75d1e 100644 --- a/src/telegram/_paidmedia.py +++ b/src/telegram/_paidmedia.py @@ -31,8 +31,8 @@ from telegram._utils.argumentparsing import ( de_json_optional, de_list_optional, - parse_period_arg, parse_sequence_arg, + to_timedelta, ) from telegram._utils.datetime import get_timedelta_value from telegram._utils.types import JSONDict, TimePeriod @@ -160,7 +160,7 @@ def __init__( with self._unfrozen(): self.width: Optional[int] = width self.height: Optional[int] = height - self._duration: Optional[dtm.timedelta] = parse_period_arg(duration) + self._duration: Optional[dtm.timedelta] = to_timedelta(duration) self._id_attrs = (self.type, self.width, self.height, self._duration) diff --git a/src/telegram/_payment/stars/transactionpartner.py b/src/telegram/_payment/stars/transactionpartner.py index 723e4d826c7..ab02cea0a99 100644 --- a/src/telegram/_payment/stars/transactionpartner.py +++ b/src/telegram/_payment/stars/transactionpartner.py @@ -18,7 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. # pylint: disable=redefined-builtin """This module contains the classes for Telegram Stars transaction partners.""" -import datetime as dtm from collections.abc import Sequence from typing import TYPE_CHECKING, Final, Optional @@ -29,13 +28,20 @@ from telegram._telegramobject import TelegramObject from telegram._user import User from telegram._utils import enum -from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_sequence_arg, + to_timedelta, +) +from telegram._utils.types import JSONDict, TimePeriod from .affiliateinfo import AffiliateInfo from .revenuewithdrawalstate import RevenueWithdrawalState if TYPE_CHECKING: + import datetime as dtm + from telegram import Bot @@ -312,11 +318,14 @@ class TransactionPartnerUser(TransactionPartner): invoice_payload (:obj:`str`, optional): Bot-specified invoice payload. Can be available only for :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` transactions. - subscription_period (:class:`datetime.timedelta`, optional): The duration of the paid - subscription. Can be available only for + subscription_period (:obj:`int` | :class:`datetime.timedelta`, optional): The duration of + the paid subscription. Can be available only for :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` transactions. .. versionadded:: 21.8 + + .. versionchanged:: NEXT.VERSION + Accepts :obj:`int` objects as well as :class:`datetime.timedelta`. paid_media (Sequence[:class:`telegram.PaidMedia`], optional): Information about the paid media bought by the user. for :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` @@ -411,7 +420,7 @@ def __init__( invoice_payload: Optional[str] = None, paid_media: Optional[Sequence[PaidMedia]] = None, paid_media_payload: Optional[str] = None, - subscription_period: Optional[dtm.timedelta] = None, + subscription_period: Optional[TimePeriod] = None, gift: Optional[Gift] = None, affiliate: Optional[AffiliateInfo] = None, premium_subscription_duration: Optional[int] = None, @@ -432,7 +441,7 @@ def __init__( self.invoice_payload: Optional[str] = invoice_payload self.paid_media: Optional[tuple[PaidMedia, ...]] = parse_sequence_arg(paid_media) self.paid_media_payload: Optional[str] = paid_media_payload - self.subscription_period: Optional[dtm.timedelta] = subscription_period + self.subscription_period: Optional[dtm.timedelta] = to_timedelta(subscription_period) self.gift: Optional[Gift] = gift self.premium_subscription_duration: Optional[int] = premium_subscription_duration self.transaction_type: str = transaction_type @@ -451,11 +460,6 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "TransactionPar data["user"] = de_json_optional(data.get("user"), User, bot) data["affiliate"] = de_json_optional(data.get("affiliate"), AffiliateInfo, bot) data["paid_media"] = de_list_optional(data.get("paid_media"), PaidMedia, bot) - data["subscription_period"] = ( - dtm.timedelta(seconds=sp) - if (sp := data.get("subscription_period")) is not None - else None - ) data["gift"] = de_json_optional(data.get("gift"), Gift, bot) return super().de_json(data=data, bot=bot) # type: ignore[return-value] diff --git a/src/telegram/_poll.py b/src/telegram/_poll.py index 4784febd16c..eaa3b8a33c0 100644 --- a/src/telegram/_poll.py +++ b/src/telegram/_poll.py @@ -30,8 +30,8 @@ from telegram._utils.argumentparsing import ( de_json_optional, de_list_optional, - parse_period_arg, parse_sequence_arg, + to_timedelta, ) from telegram._utils.datetime import ( extract_tzinfo_from_defaults, @@ -465,7 +465,7 @@ def __init__( self.explanation_entities: tuple[MessageEntity, ...] = parse_sequence_arg( explanation_entities ) - self._open_period: Optional[dtm.timedelta] = parse_period_arg(open_period) + self._open_period: Optional[dtm.timedelta] = to_timedelta(open_period) self.close_date: Optional[dtm.datetime] = close_date self.question_entities: tuple[MessageEntity, ...] = parse_sequence_arg(question_entities) @@ -493,7 +493,6 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Poll": data["question_entities"] = de_list_optional( data.get("question_entities"), MessageEntity, bot ) - data["open_period"] = dtm.timedelta(seconds=s) if (s := data.get("open_period")) else None return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_telegramobject.py b/src/telegram/_telegramobject.py index aabedeb9f05..11cf004a3bf 100644 --- a/src/telegram/_telegramobject.py +++ b/src/telegram/_telegramobject.py @@ -531,6 +531,7 @@ def _get_attrs_names(self, include_private: bool) -> Iterator[str]: return ( attr for attr in all_attrs + # Include deprecated private attributes, which are exposed via properties if not attr.startswith("_") or self._is_deprecated_attr(attr) ) diff --git a/src/telegram/_utils/argumentparsing.py b/src/telegram/_utils/argumentparsing.py index 70ef767cbed..5fa61939fb8 100644 --- a/src/telegram/_utils/argumentparsing.py +++ b/src/telegram/_utils/argumentparsing.py @@ -51,7 +51,7 @@ def parse_sequence_arg(arg: Optional[Sequence[T]]) -> tuple[T, ...]: return tuple(arg) if arg else () -def parse_period_arg(arg: Optional[Union[int, float, dtm.timedelta]]) -> Optional[dtm.timedelta]: +def to_timedelta(arg: Optional[Union[int, float, dtm.timedelta]]) -> Optional[dtm.timedelta]: """Parses an optional time period in seconds into a timedelta Args: diff --git a/src/telegram/_utils/datetime.py b/src/telegram/_utils/datetime.py index f3bf2abec43..492da697b24 100644 --- a/src/telegram/_utils/datetime.py +++ b/src/telegram/_utils/datetime.py @@ -250,20 +250,20 @@ def get_timedelta_value( Returns: - :obj:`None` if :paramref:`value` is None. - - :obj:`datetime.timedelta` if `PTB_TIMEDELTA=true`. + - :obj:`datetime.timedelta` if `PTB_TIMEDELTA=true` or ``PTB_TIMEDELTA=1``. - :obj:`int` if the total seconds is a whole number. - float: otherwise. """ if value is None: return None - if os.getenv("PTB_TIMEDELTA", "false").lower().strip() == "true": + if os.getenv("PTB_TIMEDELTA", "false").lower().strip() in ["true", "1"]: return value warn( PTBDeprecationWarning( "NEXT.VERSION", f"In a future major version attribute `{attribute}` will be of type" " `datetime.timedelta`. You can opt-in early by setting `PTB_TIMEDELTA=true`" - " as an environment variable.", + " or ``PTB_TIMEDELTA=1`` as an environment variable.", ), stacklevel=2, ) diff --git a/src/telegram/_videochat.py b/src/telegram/_videochat.py index bf5b4ee3593..aac6bfb4ca8 100644 --- a/src/telegram/_videochat.py +++ b/src/telegram/_videochat.py @@ -23,7 +23,7 @@ from telegram._telegramobject import TelegramObject from telegram._user import User -from telegram._utils.argumentparsing import parse_period_arg, parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta from telegram._utils.datetime import ( extract_tzinfo_from_defaults, from_timestamp, @@ -94,7 +94,7 @@ def __init__( api_kwargs: Optional[JSONDict] = None, ) -> None: super().__init__(api_kwargs=api_kwargs) - self._duration: dtm.timedelta = parse_period_arg(duration) # type: ignore[assignment] + self._duration: dtm.timedelta = to_timedelta(duration) # type: ignore[assignment] self._id_attrs = (self._duration,) self._freeze() @@ -105,15 +105,6 @@ def duration(self) -> Union[int, dtm.timedelta]: self._duration, attribute="duration" ) - @classmethod - def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "VideoChatEnded": - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - - data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None - - return super().de_json(data=data, bot=bot) - class VideoChatParticipantsInvited(TelegramObject): """ diff --git a/src/telegram/error.py b/src/telegram/error.py index 309836febd5..c7d918b938d 100644 --- a/src/telegram/error.py +++ b/src/telegram/error.py @@ -25,7 +25,7 @@ import datetime as dtm from typing import Optional, Union -from telegram._utils.argumentparsing import parse_period_arg +from telegram._utils.argumentparsing import to_timedelta from telegram._utils.datetime import get_timedelta_value from telegram._utils.types import TimePeriod @@ -231,9 +231,7 @@ class RetryAfter(TelegramError): __slots__ = ("_retry_after",) def __init__(self, retry_after: TimePeriod): - self._retry_after: dtm.timedelta = parse_period_arg( # type: ignore[assignment] - retry_after - ) + self._retry_after: dtm.timedelta = to_timedelta(retry_after) # type: ignore[assignment] if isinstance(self.retry_after, int): super().__init__(f"Flood control exceeded. Retry in {self.retry_after} seconds") @@ -249,6 +247,7 @@ def retry_after(self) -> Union[int, dtm.timedelta]: # noqa: D102 def __reduce__(self) -> tuple[type, tuple[float]]: # type: ignore[override] # Until support for `int` time periods is lifted, leave pickle behaviour the same + # tag: deprecated: NEXT.VERSION return self.__class__, (int(self._retry_after.total_seconds()),) diff --git a/src/telegram/ext/_application.py b/src/telegram/ext/_application.py index 30a786cf038..ea14fcdcfa4 100644 --- a/src/telegram/ext/_application.py +++ b/src/telegram/ext/_application.py @@ -782,7 +782,8 @@ def run_polling( poll_interval (:obj:`float`, optional): Time to wait between polling updates from Telegram in seconds. Default is ``0.0``. timeout (:obj:`int` | :class:`datetime.timedelta`, optional): Passed to - :paramref:`telegram.Bot.get_updates.timeout`. Default is ``timedelta(seconds=10)``. + :paramref:`telegram.Bot.get_updates.timeout`. + Default is :obj:`timedelta(seconds=10)`. .. versionchanged:: NEXT.VERSION |time-period-input| diff --git a/tests/_files/test_video.py b/tests/_files/test_video.py index ee2bfd81aa1..b701c11928a 100644 --- a/tests/_files/test_video.py +++ b/tests/_files/test_video.py @@ -39,11 +39,15 @@ from tests.auxil.slots import mro_slots +# Override `video` fixture to provide start_timestamp @pytest.fixture(scope="module") -def video(video): - with video._unfrozen(): - video._start_timestamp = VideoTestBase.start_timestamp - return video +async def video(bot, chat_id): + with data_file("telegram.mp4").open("rb") as f: + return ( + await bot.send_video( + chat_id, video=f, start_timestamp=VideoTestBase.start_timestamp, read_timeout=50 + ) + ).video class VideoTestBase: diff --git a/tests/auxil/envvars.py b/tests/auxil/envvars.py index 5fb2d20c8a1..890c9e20bbb 100644 --- a/tests/auxil/envvars.py +++ b/tests/auxil/envvars.py @@ -24,7 +24,7 @@ def env_var_2_bool(env_var: object) -> bool: return env_var if not isinstance(env_var, str): return False - return env_var.lower().strip() == "true" + return env_var.lower().strip() in ["true", "1"] GITHUB_ACTIONS: bool = env_var_2_bool(os.getenv("GITHUB_ACTIONS", "false")) diff --git a/tests/conftest.py b/tests/conftest.py index 9c1e6397c50..f9725136ccc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -135,7 +135,7 @@ def _disallow_requests_in_without_request_tests(request): ) -@pytest.fixture(scope="module", params=["true", "false", None]) +@pytest.fixture(scope="module", params=["true", "1", "false", "gibberish", None]) def PTB_TIMEDELTA(request): # Here we manually use monkeypatch to give this fixture module scope monkeypatch = pytest.MonkeyPatch() diff --git a/tests/test_official/arg_type_checker.py b/tests/test_official/arg_type_checker.py index f5e7cb414e5..19ec1825014 100644 --- a/tests/test_official/arg_type_checker.py +++ b/tests/test_official/arg_type_checker.py @@ -68,7 +68,7 @@ """, re.VERBOSE, ) -TIMEDELTA_REGEX = re.compile(r"(in|number of) seconds") +TIMEDELTA_REGEX = re.compile(r"((in|number of) seconds)|(\w+_period$)") log = logging.debug @@ -194,7 +194,9 @@ def check_param_type( mapped_type = dtm.datetime if is_class else mapped_type | dtm.datetime # 4) HANDLING TIMEDELTA: - elif re.search(TIMEDELTA_REGEX, tg_parameter.param_description): + elif re.search(TIMEDELTA_REGEX, tg_parameter.param_description) or re.search( + TIMEDELTA_REGEX, ptb_param.name + ): log("Checking that `%s` is a timedelta!\n", ptb_param.name) mapped_type = mapped_type | dtm.timedelta diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index d86819d649b..cd87cb62a22 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -103,9 +103,6 @@ class ParamTypeCheckingExceptions: "photo": str, # actual: Union[str, FileInput] "video": str, # actual: Union[str, FileInput] }, - "TransactionPartnerUser": { - "subscription_period": int, # actual: Union[int, dtm.timedelta] - }, "EncryptedPassportElement": { "data": str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress] }, From 11ac7157e3e5ecc74adcdb20bd68a15e2dc3f00e Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Tue, 10 Jun 2025 02:25:43 +0300 Subject: [PATCH 27/30] review: update handling of deprecation logic in telegramobject. --- src/telegram/_telegramobject.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/telegram/_telegramobject.py b/src/telegram/_telegramobject.py index 11cf004a3bf..66cf5dde022 100644 --- a/src/telegram/_telegramobject.py +++ b/src/telegram/_telegramobject.py @@ -570,7 +570,7 @@ def _get_attrs( if recursive and hasattr(value, "to_dict"): data[key] = value.to_dict(recursive=True) else: - data[key.removeprefix("_") if self._is_deprecated_attr(key) else key] = value + data[key] = value elif not recursive: data[key] = value @@ -615,6 +615,7 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # datetimes to timestamps. This mostly eliminates the need for subclasses to override # `to_dict` pop_keys: set[str] = set() + timedelta_dict: dict = {} for key, value in out.items(): if isinstance(value, (tuple, list)): if not value: @@ -643,13 +644,21 @@ def to_dict(self, recursive: bool = True) -> JSONDict: elif isinstance(value, dtm.timedelta): # Converting to int here is neccassry in some cases where Bot API returns # 'BadRquest' when expecting integers (e.g. Video.duration) - out[key] = ( + # not updating `out` directly to avoid changing the dict size during iteration + timedelta_dict[key.removeprefix("_")] = ( int(seconds) if (seconds := value.total_seconds()).is_integer() else seconds ) + # This will sometimes add non-deprecated timedelta attributes to pop_keys. + # we'll restore them shortly. + pop_keys.add(key) for key in pop_keys: out.pop(key) + # `out.update` must to be called *after* we pop deprecated time period attributes + # this ensures that we restore attributes that were already using datetime.timdelta + out.update(timedelta_dict) + # Effectively "unpack" api_kwargs into `out`: out.update(out.pop("api_kwargs", {})) # type: ignore[call-overload] return out From e9a63c2575f03c9e3afdfa421b4d030cc70ca04d Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Tue, 10 Jun 2025 02:38:45 +0300 Subject: [PATCH 28/30] fix typo in docstring of `TO._is_deprecated_attr` --- src/telegram/_telegramobject.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/telegram/_telegramobject.py b/src/telegram/_telegramobject.py index 66cf5dde022..9f42f29f3e1 100644 --- a/src/telegram/_telegramobject.py +++ b/src/telegram/_telegramobject.py @@ -500,7 +500,7 @@ def _apply_api_kwargs(self, api_kwargs: JSONDict) -> None: setattr(self, key, api_kwargs.pop(key)) def _is_deprecated_attr(self, attr: str) -> bool: - """Checks wheather `attr` is in the list of deprecated time period attributes.""" + """Checks whether `attr` is in the list of deprecated time period attributes.""" return ( (class_name := self.__class__.__name__) in TelegramObject._TIME_PERIOD_DEPRECATIONS and attr in TelegramObject._TIME_PERIOD_DEPRECATIONS[class_name] From 0ef30414ba30086f1bcb332beecc65f0f97e4c11 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Mon, 16 Jun 2025 18:08:46 +0300 Subject: [PATCH 29/30] review: some other points. --- src/telegram/_files/animation.py | 2 +- src/telegram/_files/audio.py | 2 +- src/telegram/_files/video.py | 2 +- src/telegram/_files/videonote.py | 2 +- src/telegram/_files/voice.py | 2 +- .../_messageautodeletetimerchanged.py | 4 +- src/telegram/_telegramobject.py | 64 +++++++++---------- src/telegram/_utils/argumentparsing.py | 14 +++- src/telegram/_videochat.py | 2 +- src/telegram/error.py | 2 +- 10 files changed, 53 insertions(+), 43 deletions(-) diff --git a/src/telegram/_files/animation.py b/src/telegram/_files/animation.py index 90d4a8c7e57..8092888466b 100644 --- a/src/telegram/_files/animation.py +++ b/src/telegram/_files/animation.py @@ -107,7 +107,7 @@ def __init__( # Required self.width: int = width self.height: int = height - self._duration: dtm.timedelta = to_timedelta(duration) # type: ignore[assignment] + self._duration: dtm.timedelta = to_timedelta(duration) # Optional self.mime_type: Optional[str] = mime_type self.file_name: Optional[str] = file_name diff --git a/src/telegram/_files/audio.py b/src/telegram/_files/audio.py index f408d6b5845..a6ba97bbe27 100644 --- a/src/telegram/_files/audio.py +++ b/src/telegram/_files/audio.py @@ -107,7 +107,7 @@ def __init__( ) with self._unfrozen(): # Required - self._duration: dtm.timedelta = to_timedelta(duration) # type: ignore[assignment] + self._duration: dtm.timedelta = to_timedelta(duration) # Optional self.performer: Optional[str] = performer self.title: Optional[str] = title diff --git a/src/telegram/_files/video.py b/src/telegram/_files/video.py index 7998879be3f..c36676f9194 100644 --- a/src/telegram/_files/video.py +++ b/src/telegram/_files/video.py @@ -138,7 +138,7 @@ def __init__( # Required self.width: int = width self.height: int = height - self._duration: dtm.timedelta = to_timedelta(duration) # type: ignore[assignment] + self._duration: dtm.timedelta = to_timedelta(duration) # Optional self.mime_type: Optional[str] = mime_type self.file_name: Optional[str] = file_name diff --git a/src/telegram/_files/videonote.py b/src/telegram/_files/videonote.py index 0997980ad9c..1c9c10b6cca 100644 --- a/src/telegram/_files/videonote.py +++ b/src/telegram/_files/videonote.py @@ -98,7 +98,7 @@ def __init__( with self._unfrozen(): # Required self.length: int = length - self._duration: dtm.timedelta = to_timedelta(duration) # type: ignore[assignment] + self._duration: dtm.timedelta = to_timedelta(duration) @property def duration(self) -> Union[int, dtm.timedelta]: diff --git a/src/telegram/_files/voice.py b/src/telegram/_files/voice.py index c8528fd1728..76baf456aa9 100644 --- a/src/telegram/_files/voice.py +++ b/src/telegram/_files/voice.py @@ -82,7 +82,7 @@ def __init__( ) with self._unfrozen(): # Required - self._duration: dtm.timedelta = to_timedelta(duration) # type: ignore[assignment] + self._duration: dtm.timedelta = to_timedelta(duration) # Optional self.mime_type: Optional[str] = mime_type diff --git a/src/telegram/_messageautodeletetimerchanged.py b/src/telegram/_messageautodeletetimerchanged.py index 06b0a0ca49f..c8f51c0c672 100644 --- a/src/telegram/_messageautodeletetimerchanged.py +++ b/src/telegram/_messageautodeletetimerchanged.py @@ -62,9 +62,7 @@ def __init__( api_kwargs: Optional[JSONDict] = None, ): super().__init__(api_kwargs=api_kwargs) - self._message_auto_delete_time: dtm.timedelta = to_timedelta( - message_auto_delete_time - ) # type: ignore[assignment] + self._message_auto_delete_time: dtm.timedelta = to_timedelta(message_auto_delete_time) self._id_attrs = (self.message_auto_delete_time,) diff --git a/src/telegram/_telegramobject.py b/src/telegram/_telegramobject.py index 9f42f29f3e1..caf384ced51 100644 --- a/src/telegram/_telegramobject.py +++ b/src/telegram/_telegramobject.py @@ -502,9 +502,8 @@ def _apply_api_kwargs(self, api_kwargs: JSONDict) -> None: def _is_deprecated_attr(self, attr: str) -> bool: """Checks whether `attr` is in the list of deprecated time period attributes.""" return ( - (class_name := self.__class__.__name__) in TelegramObject._TIME_PERIOD_DEPRECATIONS - and attr in TelegramObject._TIME_PERIOD_DEPRECATIONS[class_name] - ) + class_name := self.__class__.__name__ + ) in _TIME_PERIOD_DEPRECATIONS and attr in _TIME_PERIOD_DEPRECATIONS[class_name] def _get_attrs_names(self, include_private: bool) -> Iterator[str]: """ @@ -643,13 +642,13 @@ def to_dict(self, recursive: bool = True) -> JSONDict: out[key] = to_timestamp(value) elif isinstance(value, dtm.timedelta): # Converting to int here is neccassry in some cases where Bot API returns - # 'BadRquest' when expecting integers (e.g. Video.duration) - # not updating `out` directly to avoid changing the dict size during iteration + # 'BadRquest' when expecting integers (e.g. InputMediaVideo.duration) + # Not updating `out` directly to avoid changing the dict size during iteration timedelta_dict[key.removeprefix("_")] = ( int(seconds) if (seconds := value.total_seconds()).is_integer() else seconds ) # This will sometimes add non-deprecated timedelta attributes to pop_keys. - # we'll restore them shortly. + # We'll restore them shortly. pop_keys.add(key) for key in pop_keys: @@ -691,29 +690,30 @@ def set_bot(self, bot: Optional["Bot"]) -> None: """ self._bot = bot - # We use str keys to avoid importing which causes circular dependencies - _TIME_PERIOD_DEPRECATIONS: ClassVar = { - "ChatFullInfo": ("_message_auto_delete_time", "_slow_mode_delay"), - "Animation": ("_duration",), - "Audio": ("_duration",), - "Video": ("_duration", "_start_timestamp"), - "VideoNote": ("_duration",), - "Voice": ("_duration",), - "PaidMediaPreview": ("_duration",), - "VideoChatEnded": ("_duration",), - "InputMediaVideo": ("_duration",), - "InputMediaAnimation": ("_duration",), - "InputMediaAudio": ("_duration",), - "InputPaidMediaVideo": ("_duration",), - "InlineQueryResultGif": ("_gif_duration",), - "InlineQueryResultMpeg4Gif": ("_mpeg4_duration",), - "InlineQueryResultVideo": ("_video_duration",), - "InlineQueryResultAudio": ("_audio_duration",), - "InlineQueryResultVoice": ("_voice_duration",), - "InlineQueryResultLocation": ("_live_period",), - "Poll": ("_open_period",), - "Location": ("_live_period",), - "MessageAutoDeleteTimerChanged": ("_message_auto_delete_time",), - "ChatInviteLink": ("_subscription_period",), - "InputLocationMessageContent": ("_live_period",), - } + +# We use str keys to avoid importing which causes circular dependencies +_TIME_PERIOD_DEPRECATIONS: dict[str, tuple[str, ...]] = { + "ChatFullInfo": ("_message_auto_delete_time", "_slow_mode_delay"), + "Animation": ("_duration",), + "Audio": ("_duration",), + "Video": ("_duration", "_start_timestamp"), + "VideoNote": ("_duration",), + "Voice": ("_duration",), + "PaidMediaPreview": ("_duration",), + "VideoChatEnded": ("_duration",), + "InputMediaVideo": ("_duration",), + "InputMediaAnimation": ("_duration",), + "InputMediaAudio": ("_duration",), + "InputPaidMediaVideo": ("_duration",), + "InlineQueryResultGif": ("_gif_duration",), + "InlineQueryResultMpeg4Gif": ("_mpeg4_duration",), + "InlineQueryResultVideo": ("_video_duration",), + "InlineQueryResultAudio": ("_audio_duration",), + "InlineQueryResultVoice": ("_voice_duration",), + "InlineQueryResultLocation": ("_live_period",), + "Poll": ("_open_period",), + "Location": ("_live_period",), + "MessageAutoDeleteTimerChanged": ("_message_auto_delete_time",), + "ChatInviteLink": ("_subscription_period",), + "InputLocationMessageContent": ("_live_period",), +} diff --git a/src/telegram/_utils/argumentparsing.py b/src/telegram/_utils/argumentparsing.py index 5fa61939fb8..acebbf06440 100644 --- a/src/telegram/_utils/argumentparsing.py +++ b/src/telegram/_utils/argumentparsing.py @@ -25,7 +25,7 @@ """ import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional, Protocol, TypeVar, Union +from typing import TYPE_CHECKING, Optional, Protocol, TypeVar, Union, overload from telegram._linkpreviewoptions import LinkPreviewOptions from telegram._telegramobject import TelegramObject @@ -51,6 +51,18 @@ def parse_sequence_arg(arg: Optional[Sequence[T]]) -> tuple[T, ...]: return tuple(arg) if arg else () +@overload +def to_timedelta(arg: None) -> None: ... + + +@overload +def to_timedelta( + arg: Union[ # noqa: PYI041 (be more explicit about `int` and `float` arguments) + int, float, dtm.timedelta + ], +) -> dtm.timedelta: ... + + def to_timedelta(arg: Optional[Union[int, float, dtm.timedelta]]) -> Optional[dtm.timedelta]: """Parses an optional time period in seconds into a timedelta diff --git a/src/telegram/_videochat.py b/src/telegram/_videochat.py index aac6bfb4ca8..7d59c67f33e 100644 --- a/src/telegram/_videochat.py +++ b/src/telegram/_videochat.py @@ -94,7 +94,7 @@ def __init__( api_kwargs: Optional[JSONDict] = None, ) -> None: super().__init__(api_kwargs=api_kwargs) - self._duration: dtm.timedelta = to_timedelta(duration) # type: ignore[assignment] + self._duration: dtm.timedelta = to_timedelta(duration) self._id_attrs = (self._duration,) self._freeze() diff --git a/src/telegram/error.py b/src/telegram/error.py index c7d918b938d..c21d5bef477 100644 --- a/src/telegram/error.py +++ b/src/telegram/error.py @@ -231,7 +231,7 @@ class RetryAfter(TelegramError): __slots__ = ("_retry_after",) def __init__(self, retry_after: TimePeriod): - self._retry_after: dtm.timedelta = to_timedelta(retry_after) # type: ignore[assignment] + self._retry_after: dtm.timedelta = to_timedelta(retry_after) if isinstance(self.retry_after, int): super().__init__(f"Flood control exceeded. Retry in {self.retry_after} seconds") From 5aabf59b1ee236f1f228173c98708dcb826151f3 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sun, 29 Jun 2025 01:08:08 +0300 Subject: [PATCH 30/30] Mock business float period properties. --- src/telegram/_telegramobject.py | 4 ++- tests/_files/test_inputstorycontent.py | 21 +++++++++++++++- tests/test_business_methods.py | 34 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/telegram/_telegramobject.py b/src/telegram/_telegramobject.py index caf384ced51..a05c116633c 100644 --- a/src/telegram/_telegramobject.py +++ b/src/telegram/_telegramobject.py @@ -642,7 +642,9 @@ def to_dict(self, recursive: bool = True) -> JSONDict: out[key] = to_timestamp(value) elif isinstance(value, dtm.timedelta): # Converting to int here is neccassry in some cases where Bot API returns - # 'BadRquest' when expecting integers (e.g. InputMediaVideo.duration) + # 'BadRquest' when expecting integers (e.g. InputMediaVideo.duration). + # Other times, floats are accepted but the Bot API handles ints just as well + # (e.g. InputStoryContentVideo.duration). # Not updating `out` directly to avoid changing the dict size during iteration timedelta_dict[key.removeprefix("_")] = ( int(seconds) if (seconds := value.total_seconds()).is_integer() else seconds diff --git a/tests/_files/test_inputstorycontent.py b/tests/_files/test_inputstorycontent.py index 9e826409584..37762a24e1a 100644 --- a/tests/_files/test_inputstorycontent.py +++ b/tests/_files/test_inputstorycontent.py @@ -107,7 +107,7 @@ class InputStoryContentVideoTestBase: is_animation = False -class TestInputMediaVideoWithoutRequest(InputStoryContentVideoTestBase): +class TestInputStoryContentVideoWithoutRequest(InputStoryContentVideoTestBase): def test_slot_behaviour(self, input_story_content_video): inst = input_story_content_video for attr in inst.__slots__: @@ -131,6 +131,25 @@ def test_to_dict(self, input_story_content_video): assert json_dict["cover_frame_timestamp"] == self.cover_frame_timestamp.total_seconds() assert json_dict["is_animation"] is self.is_animation + @pytest.mark.parametrize( + ("argument", "expected"), + [(4, 4), (4.0, 4), (dtm.timedelta(seconds=4), 4), (4.5, 4.5)], + ) + def test_to_dict_float_time_period(self, argument, expected): + # We test that whole number conversion works properly. Only tested here but + # relevant for some other classes too (e.g InputProfilePhotoAnimated.main_frame_timestamp) + inst = InputStoryContentVideo( + video=self.video.read_bytes(), + duration=argument, + cover_frame_timestamp=argument, + ) + json_dict = inst.to_dict() + + assert json_dict["duration"] == expected + assert type(json_dict["duration"]) is type(expected) + assert json_dict["cover_frame_timestamp"] == expected + assert type(json_dict["cover_frame_timestamp"]) is type(expected) + def test_with_video_file(self, video_file): inst = InputStoryContentVideo(video=video_file) assert inst.type is self.type diff --git a/tests/test_business_methods.py b/tests/test_business_methods.py index 13017eca8e6..721df6353b9 100644 --- a/tests/test_business_methods.py +++ b/tests/test_business_methods.py @@ -32,6 +32,7 @@ StoryAreaTypeUniqueGift, User, ) +from telegram._files._inputstorycontent import InputStoryContentVideo from telegram._files.sticker import Sticker from telegram._gifts import AcceptedGiftTypes, Gift from telegram._ownedgift import OwnedGiftRegular, OwnedGifts @@ -492,6 +493,39 @@ async def make_assertion(url, request_data, *args, **kwargs): await default_bot.post_story(**kwargs) + @pytest.mark.parametrize( + ("argument", "expected"), + [(4, 4), (4.0, 4), (dtm.timedelta(seconds=4), 4), (4.5, 4.5)], + ) + async def test_post_story_float_time_period( + self, offline_bot, monkeypatch, argument, expected + ): + # We test that whole number conversion works properly. Only tested here but + # relevant for some other methods too (e.g bot.set_business_account_profile_photo) + async def make_assertion(url, request_data, *args, **kwargs): + data = request_data.parameters + content = data["content"] + + assert content["duration"] == expected + assert type(content["duration"]) is type(expected) + assert content["cover_frame_timestamp"] == expected + assert type(content["cover_frame_timestamp"]) is type(expected) + + return Story(chat=Chat(123, "private"), id=123).to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "content": InputStoryContentVideo( + video=data_file("telegram.mp4"), + duration=argument, + cover_frame_timestamp=argument, + ), + "active_period": dtm.timedelta(seconds=20), + } + + assert await offline_bot.post_story(**kwargs) + async def test_edit_story_all_args(self, offline_bot, monkeypatch): story_id = 1234 content = InputStoryContentPhoto(photo=data_file("telegram.jpg").read_bytes())