diff --git a/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml b/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml new file mode 100644 index 00000000000..5d9d75d7ca9 --- /dev/null +++ b/changes/unreleased/4750.jJBu7iAgZa96hdqcpHK96W.toml @@ -0,0 +1,36 @@ +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`` 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` +- :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` +- :attr:`telegram.error.RetryAfter.retry_after` +""" +internal = "Modify `test_official` to handle time periods as timedelta automatically." +[[pull_requests]] +uid = "4750" +author_uid = "aelkheir" +closes_threads = ["4575"] diff --git a/docs/substitutions/global.rst b/docs/substitutions/global.rst index 8fb9e9360d7..ed4b40ecdee 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. + +.. |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/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/src/telegram/_bot.py b/src/telegram/_bot.py index 90f6cf0bf42..5f588f430a8 100644 --- a/src/telegram/_bot.py +++ b/src/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 timeout 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/src/telegram/_chatfullinfo.py b/src/telegram/_chatfullinfo.py index 4b0fae53c6b..7d0a5838063 100644 --- a/src/telegram/_chatfullinfo.py +++ b/src/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_sequence_arg, + to_timedelta, +) +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 + |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. .. 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 + |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. @@ -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] = 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 self.has_visible_history: Optional[bool] = has_visible_history @@ -576,6 +597,16 @@ 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, attribute="slow_mode_delay") + + @property + def message_auto_delete_time(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value( + self._message_auto_delete_time, attribute="message_auto_delete_time" + ) + @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo": """See :meth:`telegram.TelegramObject.de_json`.""" diff --git a/src/telegram/_chatinvitelink.py b/src/telegram/_chatinvitelink.py index 289ee48bdba..dc5486924e6 100644 --- a/src/telegram/_chatinvitelink.py +++ b/src/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, to_timedelta +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] = to_timedelta(subscription_period) self.subscription_price: Optional[int] = subscription_price self._id_attrs = ( @@ -177,6 +187,10 @@ def __init__( self._freeze() + @property + def subscription_period(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value(self._subscription_period, attribute="subscription_period") + @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatInviteLink": """See :meth:`telegram.TelegramObject.de_json`.""" diff --git a/src/telegram/_files/_inputstorycontent.py b/src/telegram/_files/_inputstorycontent.py index 1eaf14682f3..dd8f25c5810 100644 --- a/src/telegram/_files/_inputstorycontent.py +++ b/src/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 to_timedelta 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] = to_timedelta(duration) + self.cover_frame_timestamp: Optional[dtm.timedelta] = to_timedelta( 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/src/telegram/_files/animation.py b/src/telegram/_files/animation.py index 537ffc0a0db..8092888466b 100644 --- a/src/telegram/_files/animation.py +++ b/src/telegram/_files/animation.py @@ -17,11 +17,14 @@ # 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 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 to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class Animation(_BaseThumbedMedium): @@ -41,7 +44,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 +65,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 +80,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 +88,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 +107,13 @@ def __init__( # Required self.width: int = width self.height: int = height - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) # Optional self.mime_type: Optional[str] = mime_type self.file_name: Optional[str] = file_name + + @property + def duration(self) -> Union[int, dtm.timedelta]: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) diff --git a/src/telegram/_files/audio.py b/src/telegram/_files/audio.py index af5e420e1b2..a6ba97bbe27 100644 --- a/src/telegram/_files/audio.py +++ b/src/telegram/_files/audio.py @@ -17,11 +17,14 @@ # 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 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 to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class Audio(_BaseThumbedMedium): @@ -39,7 +42,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 +63,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 +82,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 +107,15 @@ def __init__( ) with self._unfrozen(): # Required - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) # 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]: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) diff --git a/src/telegram/_files/inputmedia.py b/src/telegram/_files/inputmedia.py index 2b7e6b21fd5..5746fd5b1ba 100644 --- a/src/telegram/_files/inputmedia.py +++ b/src/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_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 -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] @@ -215,7 +217,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 +238,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 +262,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 +272,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 +286,17 @@ def __init__( ) self.width: Optional[int] = width self.height: Optional[int] = height - self.duration: Optional[int] = 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 ) self.start_timestamp: Optional[int] = start_timestamp + @property + def duration(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value(self._duration, attribute="duration") + class InputMediaAnimation(InputMedia): """Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. @@ -322,7 +334,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 +366,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 +384,7 @@ class InputMediaAnimation(InputMedia): """ __slots__ = ( - "duration", + "_duration", "has_spoiler", "height", "show_caption_above_media", @@ -379,7 +399,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 +411,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 +432,14 @@ def __init__( ) self.width: Optional[int] = width self.height: Optional[int] = height - self.duration: Optional[int] = 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 + @property + def duration(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value(self._duration, attribute="duration") + class InputMediaPhoto(InputMedia): """Represents a photo to be sent. @@ -545,7 +569,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 +609,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 +635,8 @@ class InputMediaVideo(InputMedia): """ __slots__ = ( + "_duration", "cover", - "duration", "has_spoiler", "height", "show_caption_above_media", @@ -622,7 +652,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 +668,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 +686,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] = to_timedelta(duration) self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumbnail_input( thumbnail ) @@ -668,6 +698,10 @@ def __init__( ) self.start_timestamp: Optional[int] = start_timestamp + @property + def duration(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value(self._duration, attribute="duration") + class InputMediaAudio(InputMedia): """Represents an audio file to be treated as music to be sent. @@ -703,7 +737,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 +763,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 +777,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 +794,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 +815,14 @@ def __init__( self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumbnail_input( thumbnail ) - self.duration: Optional[int] = duration + self._duration: Optional[dtm.timedelta] = to_timedelta(duration) self.title: Optional[str] = title self.performer: Optional[str] = performer + @property + def duration(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value(self._duration, attribute="duration") + class InputMediaDocument(InputMedia): """Represents a general file to be sent. 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 87c895b711a..97e8da68993 100644 --- a/src/telegram/_files/location.py +++ b/src/telegram/_files/location.py @@ -18,11 +18,14 @@ # 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 Final, Optional, Union from telegram import constants from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class Location(TelegramObject): @@ -36,8 +39,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 +56,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 +71,10 @@ class Location(TelegramObject): """ __slots__ = ( + "_live_period", "heading", "horizontal_accuracy", "latitude", - "live_period", "longitude", "proximity_alert_radius", ) @@ -73,7 +84,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 +97,7 @@ def __init__( # Optionals self.horizontal_accuracy: Optional[float] = horizontal_accuracy - self.live_period: Optional[int] = 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 @@ -96,6 +107,10 @@ def __init__( self._freeze() + @property + def live_period(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value(self._live_period, attribute="live_period") + 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 36381ebbf6b..c36676f9194 100644 --- a/src/telegram/_files/video.py +++ b/src/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_sequence_arg, to_timedelta +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. @@ -57,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. @@ -69,7 +78,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. @@ -80,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", - "duration", "file_name", "height", "mime_type", - "start_timestamp", "width", ) @@ -101,13 +117,13 @@ 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, thumbnail: Optional[PhotoSize] = None, cover: Optional[Sequence[PhotoSize]] = None, - start_timestamp: Optional[int] = None, + start_timestamp: Optional[TimePeriod] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -122,12 +138,22 @@ def __init__( # Required self.width: int = width self.height: int = height - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) # 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 + self._start_timestamp: Optional[dtm.timedelta] = to_timedelta(start_timestamp) + + @property + def duration(self) -> Union[int, dtm.timedelta]: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) + + @property + def start_timestamp(self) -> Optional[Union[int, dtm.timedelta]]: + 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/src/telegram/_files/videonote.py b/src/telegram/_files/videonote.py index edb9e555372..1c9c10b6cca 100644 --- a/src/telegram/_files/videonote.py +++ b/src/telegram/_files/videonote.py @@ -18,11 +18,14 @@ # 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 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 to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class VideoNote(_BaseThumbedMedium): @@ -42,7 +45,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 +63,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 +75,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 +98,10 @@ def __init__( with self._unfrozen(): # Required self.length: int = length - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) + + @property + def duration(self) -> Union[int, dtm.timedelta]: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) diff --git a/src/telegram/_files/voice.py b/src/telegram/_files/voice.py index 19c0e856d14..76baf456aa9 100644 --- a/src/telegram/_files/voice.py +++ b/src/telegram/_files/voice.py @@ -17,10 +17,13 @@ # 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 Optional, Union from telegram._files._basemedium import _BaseMedium -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class Voice(_BaseMedium): @@ -35,7 +38,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 +52,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 +82,12 @@ def __init__( ) with self._unfrozen(): # Required - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) # Optional self.mime_type: Optional[str] = mime_type + + @property + def duration(self) -> Union[int, dtm.timedelta]: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) diff --git a/src/telegram/_inline/inlinequeryresultaudio.py b/src/telegram/_inline/inlinequeryresultaudio.py index 8e3376a458f..92b4ae81445 100644 --- a/src/telegram/_inline/inlinequeryresultaudio.py +++ b/src/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_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 +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,13 @@ def __init__( # Optionals self.performer: Optional[str] = performer - self.audio_duration: Optional[int] = 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) 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]]: + return get_timedelta_value(self._audio_duration, attribute="audio_duration") diff --git a/src/telegram/_inline/inlinequeryresultgif.py b/src/telegram/_inline/inlinequeryresultgif.py index 398d61cc79a..4ead8759989 100644 --- a/src/telegram/_inline/inlinequeryresultgif.py +++ b/src/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_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 +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] = to_timedelta(gif_duration) self.title: Optional[str] = title self.caption: Optional[str] = caption self.parse_mode: ODVInput[str] = parse_mode @@ -172,3 +182,7 @@ 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]]: + return get_timedelta_value(self._gif_duration, attribute="gif_duration") diff --git a/src/telegram/_inline/inlinequeryresultlocation.py b/src/telegram/_inline/inlinequeryresultlocation.py index 01035537840..bbe222157bc 100644 --- a/src/telegram/_inline/inlinequeryresultlocation.py +++ b/src/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 to_timedelta +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] = 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 @@ -170,6 +179,10 @@ def __init__( int(proximity_alert_radius) if proximity_alert_radius else None ) + @property + def live_period(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value(self._live_period, attribute="live_period") + 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 b47faa0186a..4a521642d01 100644 --- a/src/telegram/_inline/inlinequeryresultmpeg4gif.py +++ b/src/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_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 +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] = to_timedelta(mpeg4_duration) self.title: Optional[str] = title self.caption: Optional[str] = caption self.parse_mode: ODVInput[str] = parse_mode @@ -174,3 +184,7 @@ 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]]: + return get_timedelta_value(self._mpeg4_duration, attribute="mpeg4_duration") diff --git a/src/telegram/_inline/inlinequeryresultvideo.py b/src/telegram/_inline/inlinequeryresultvideo.py index edc6ce343ac..5b98aa00557 100644 --- a/src/telegram/_inline/inlinequeryresultvideo.py +++ b/src/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_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 +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,12 @@ 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] = 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 self.show_caption_above_media: Optional[bool] = show_caption_above_media + + @property + def video_duration(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value(self._video_duration, attribute="video_duration") diff --git a/src/telegram/_inline/inlinequeryresultvoice.py b/src/telegram/_inline/inlinequeryresultvoice.py index b798040b1aa..9dfcd0b94e0 100644 --- a/src/telegram/_inline/inlinequeryresultvoice.py +++ b/src/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_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 +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,13 @@ def __init__( self.title: str = title # Optional - self.voice_duration: Optional[int] = 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) 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]]: + return get_timedelta_value(self._voice_duration, attribute="voice_duration") diff --git a/src/telegram/_inline/inputlocationmessagecontent.py b/src/telegram/_inline/inputlocationmessagecontent.py index f71a716c259..94ea4e2d893 100644 --- a/src/telegram/_inline/inputlocationmessagecontent.py +++ b/src/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 to_timedelta +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] = to_timedelta(live_period) self.horizontal_accuracy: Optional[float] = horizontal_accuracy self.heading: Optional[int] = heading self.proximity_alert_radius: Optional[int] = ( @@ -113,6 +123,10 @@ def __init__( self._id_attrs = (self.latitude, self.longitude) + @property + def live_period(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value(self._live_period, attribute="live_period") + 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 1653c050d59..c8f51c0c672 100644 --- a/src/telegram/_messageautodeletetimerchanged.py +++ b/src/telegram/_messageautodeletetimerchanged.py @@ -20,10 +20,13 @@ deletion. """ -from typing import Optional +import datetime as dtm +from typing import Optional, Union from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class MessageAutoDeleteTimerChanged(TelegramObject): @@ -35,26 +38,38 @@ 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 = to_timedelta(message_auto_delete_time) self._id_attrs = (self.message_auto_delete_time,) self._freeze() + + @property + 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" + ) diff --git a/src/telegram/_paidmedia.py b/src/telegram/_paidmedia.py index 972c46fa333..fe8ace75d1e 100644 --- a/src/telegram/_paidmedia.py +++ b/src/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_sequence_arg, + to_timedelta, +) +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,13 @@ 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] = to_timedelta(duration) + + self._id_attrs = (self.type, self.width, self.height, self._duration) - self._id_attrs = (self.type, self.width, self.height, self.duration) + @property + def duration(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value(self._duration, attribute="duration") class PaidMediaPhoto(PaidMedia): 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/_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 8ecdc4105f9..eaa3b8a33c0 100644 --- a/src/telegram/_poll.py +++ b/src/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_sequence_arg, + to_timedelta, +) +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] = to_timedelta(open_period) self.close_date: Optional[dtm.datetime] = close_date self.question_entities: tuple[MessageEntity, ...] = parse_sequence_arg(question_entities) @@ -458,6 +473,10 @@ def __init__( self._freeze() + @property + def open_period(self) -> Optional[Union[int, dtm.timedelta]]: + return get_timedelta_value(self._open_period, attribute="open_period") + @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Poll": """See :meth:`telegram.TelegramObject.de_json`.""" diff --git a/src/telegram/_telegramobject.py b/src/telegram/_telegramobject.py index ca0d20555eb..a05c116633c 100644 --- a/src/telegram/_telegramobject.py +++ b/src/telegram/_telegramobject.py @@ -499,6 +499,12 @@ 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 whether `attr` is in the list of deprecated time period attributes.""" + return ( + 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]: """ Returns the names of the attributes of this object. This is used to determine which @@ -521,7 +527,12 @@ 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 + # Include deprecated private attributes, which are exposed via properties + if not attr.startswith("_") or self._is_deprecated_attr(attr) + ) def _get_attrs( self, @@ -603,6 +614,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: @@ -629,11 +641,25 @@ 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. 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 + ) + # 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 @@ -665,3 +691,31 @@ 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: 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 84ca1bc6a2f..acebbf06440 100644 --- a/src/telegram/_utils/argumentparsing.py +++ b/src/telegram/_utils/argumentparsing.py @@ -23,8 +23,9 @@ 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, overload from telegram._linkpreviewoptions import LinkPreviewOptions from telegram._telegramobject import TelegramObject @@ -50,6 +51,34 @@ 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 + + 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)): + 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/src/telegram/_utils/datetime.py b/src/telegram/_utils/datetime.py index 8e6ebdda1b4..492da697b24 100644 --- a/src/telegram/_utils/datetime.py +++ b/src/telegram/_utils/datetime.py @@ -29,9 +29,13 @@ """ 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 + if TYPE_CHECKING: from telegram import Bot @@ -224,3 +228,47 @@ 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], attribute: str +) -> Optional[Union[int, 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. + + Note: + When `PTB_TIMEDELTA` is not enabled, the function will issue a deprecation warning. + + Args: + 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:`None` if :paramref:`value` is None. + - :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() 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`" + " or ``PTB_TIMEDELTA=1`` as an environment variable.", + ), + stacklevel=2, + ) + return ( + int(seconds) + if (seconds := value.total_seconds()).is_integer() + else seconds # type: ignore[return-value] + ) diff --git a/src/telegram/_videochat.py b/src/telegram/_videochat.py index 7c1ec00aabb..7d59c67f33e 100644 --- a/src/telegram/_videochat.py +++ b/src/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_sequence_arg, to_timedelta +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,45 @@ 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 = to_timedelta(duration) + self._id_attrs = (self._duration,) self._freeze() + @property + def duration(self) -> Union[int, dtm.timedelta]: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) + class VideoChatParticipantsInvited(TelegramObject): """ diff --git a/src/telegram/error.py b/src/telegram/error.py index 2de0361762d..c21d5bef477 100644 --- a/src/telegram/error.py +++ b/src/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 to_timedelta +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 = to_timedelta(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]: # 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" + ) 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 + # tag: deprecated: NEXT.VERSION + return self.__class__, (int(self._retry_after.total_seconds()),) class Conflict(TelegramError): diff --git a/src/telegram/ext/_aioratelimiter.py b/src/telegram/ext/_aioratelimiter.py index f4ecf917f66..d2d537e7e27 100644 --- a/src/telegram/ext/_aioratelimiter.py +++ b/src/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/src/telegram/ext/_application.py b/src/telegram/ext/_application.py index e856fa85321..ea14fcdcfa4 100644 --- a/src/telegram/ext/_application.py +++ b/src/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,12 @@ 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 :obj:`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/src/telegram/ext/_extbot.py b/src/telegram/ext/_extbot.py index 7afadaa89fa..5781cf817bc 100644 --- a/src/telegram/ext/_extbot.py +++ b/src/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/src/telegram/ext/_updater.py b/src/telegram/ext/_updater.py index 95f7e225ed1..63634fbc467 100644 --- a/src/telegram/ext/_updater.py +++ b/src/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/src/telegram/ext/_utils/networkloop.py b/src/telegram/ext/_utils/networkloop.py index 03c54e8e8a2..2cc93113272 100644 --- a/src/telegram/ext/_utils/networkloop.py +++ b/src/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/_files/test_animation.py b/tests/_files/test_animation.py index 5ae93dd61ef..50437e69877 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,31 @@ 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) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, animation): + animation.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + 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..47d8dff9c2f 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,30 @@ 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) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, audio): + audio.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + 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 +257,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..08bdf3428a3 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,27 @@ 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): + duration = input_media_video.duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_video): + input_media_video.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + 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): # fixture found in test_video input_media_video = InputMediaVideo(video, caption="test 3") assert input_media_video.type == self.type_ @@ -324,7 +347,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 +368,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 +385,34 @@ 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): + duration = input_media_animation.duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_animation): + input_media_animation.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + 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): # fixture found in test_animation input_media_animation = InputMediaAnimation(animation, caption="test 2") @@ -394,7 +439,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 +457,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 +473,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 +483,26 @@ 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): + duration = input_media_audio.duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_audio): + input_media_audio.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + 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): # fixture found in test_audio input_media_audio = InputMediaAudio(audio, caption="test 3") @@ -574,7 +641,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 +653,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 +666,26 @@ 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): + duration = input_paid_media_video.duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_paid_media_video): + input_paid_media_video.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + 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): # fixture found in test_video input_paid_media_video = InputPaidMediaVideo(video) 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/_files/test_location.py b/tests/_files/test_location.py index 5ccddbac527..30cfb20595f 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 "`live_period` 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 d4d87122576..b701c11928a 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, @@ -38,15 +39,26 @@ from tests.auxil.slots import mro_slots +# Override `video` fixture to provide start_timestamp +@pytest.fixture(scope="module") +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: width = 360 height = 640 - duration = 5 + duration = dtm.timedelta(seconds=5) file_size = 326534 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 @@ -80,9 +92,10 @@ 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 + assert video._start_timestamp == self.start_timestamp def test_de_json(self, offline_bot): json_dict = { @@ -90,11 +103,11 @@ 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, - "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) @@ -104,11 +117,11 @@ 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 - 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): @@ -119,10 +132,39 @@ 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 + 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) == 2 + 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): a = Video(video.file_id, video.file_unique_id, self.width, self.height, self.duration) @@ -266,7 +308,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/_files/test_videonote.py b/tests/_files/test_videonote.py index 5edab597806..40f853bca52 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,28 @@ 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) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, video_note): + video_note.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + 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): 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..62fdb4e79f8 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,29 @@ 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) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, voice): + voice.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + 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/_inline/test_inlinequeryresultaudio.py b/tests/_inline/test_inlinequeryresultaudio.py index 4c781655910..17871fa854d 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,28 @@ 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 "`audio_duration` 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..2806e895623 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 "`gif_duration` 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..a9471f0d55d 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 "`live_period` 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..4c8291c4e5a 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,30 @@ 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 "`mpeg4_duration` 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..dd07b9c9719 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,29 @@ 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 "`video_duration` 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..f4e58cca371 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,28 @@ 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 "`voice_duration` 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/_inline/test_inputlocationmessagecontent.py b/tests/_inline/test_inputlocationmessagecontent.py index 05e86086852..1fd79ee9ad0 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 "`live_period` 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/_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) 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 935daada498..f9725136ccc 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", "1", "false", "gibberish", 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") 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/ext/test_updater.py b/tests/ext/test_updater.py index 147fc6128df..92a2d65ce7d 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", } @@ -416,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, } @@ -456,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() 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"}}' 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( 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()) diff --git a/tests/test_chatfullinfo.py b/tests/test_chatfullinfo.py index dff26aa7398..52444fcbd34 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, @@ -106,7 +107,8 @@ class ChatFullInfoTestBase: can_change_info=False, can_invite_users=True, ) - slow_mode_delay = 30 + 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") @@ -168,7 +170,8 @@ def test_de_json(self, offline_bot): "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, + "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(), @@ -201,6 +204,7 @@ def test_de_json(self, offline_bot): "last_name": self.last_name, "can_send_paid_media": self.can_send_paid_media, } + cfi = ChatFullInfo.de_json(json_dict, offline_bot) assert cfi.api_kwargs == {} assert cfi.id == self.id_ @@ -211,7 +215,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 == 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 @@ -281,7 +286,10 @@ def test_to_dict(self, chat_full_info): 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() @@ -355,6 +363,35 @@ 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) + + 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 PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 2 + 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): cfi = ChatFullInfo( id=123, diff --git a/tests/test_chatinvitelink.py b/tests/test_chatinvitelink.py index 55cfc5763a9..f111d7bf2b6 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,32 @@ 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 "`subscription_period` 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_error.py b/tests/test_error.py index 9fd0ba707fc..863ec0c4c5e 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 "`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 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')" diff --git a/tests/test_messageautodeletetimerchanged.py b/tests/test_messageautodeletetimerchanged.py index 19133e9aaa9..9e0ab16476f 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,47 @@ 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 "`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): a = MessageAutoDeleteTimerChanged(100) diff --git a/tests/test_official/arg_type_checker.py b/tests/test_official/arg_type_checker.py index 4b0e3630691..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"\w+_period$") # Parameter names ending with "_period" +TIMEDELTA_REGEX = re.compile(r"((in|number of) seconds)|(\w+_period$)") log = logging.debug @@ -194,15 +194,11 @@ 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", + elif re.search(TIMEDELTA_REGEX, tg_parameter.param_description) or re.search( + TIMEDELTA_REGEX, ptb_param.name ): - # 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 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 40144f803d3..cd87cb62a22 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,8 +102,6 @@ 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 }, "EncryptedPassportElement": { "data": str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress] diff --git a/tests/test_paidmedia.py b/tests/test_paidmedia.py index a696c416b58..8055e161e84 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,26 @@ 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): + duration = paid_media_preview.duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, paid_media_preview): + paid_media_preview.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + 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 c7e3da447f5..484e18710a2 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 "`open_period` 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) diff --git a/tests/test_videochat.py b/tests/test_videochat.py index 57d91003c29..df8151940cf 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,50 @@ 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): + duration = VideoChatEnded(duration=self.duration).duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA): + VideoChatEnded(self.duration).duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning 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)