Skip to content

Use timedelta to represent time periods in classes #4750

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 33 commits into from
Jun 29, 2025

Conversation

aelkheir
Copy link
Member

@aelkheir aelkheir commented Apr 9, 2025

Second part of #4575.

Affected classes (basicaly anywhere "in seconds" appears in api docs)
Class attribute Type docs
ChatFullInfo slow_mode_delay Integer Optional. For supergroups, the minimum allowed delay between consecutive messages sent by each unprivileged user; in seconds
ChatFullInfo message_auto_delete_time Integer Optional. The time after which all messages sent to the chat will be automatically deleted; in seconds
Animation duration Integer Duration of the video in seconds as defined by the sender
Audio duration Integer Duration of the audio in seconds as defined by the sender
Video duration Integer Duration of the video in seconds as defined by the sender
Video start_timestamp Integer Optional. Timestamp in seconds from which the video will play in the message
VideoNote duration Integer Duration of the video in seconds as defined by the sender
Voice duration Integer Duration of the audio in seconds as defined by the sender
PaidMediaPreview duration Integer Optional. Duration of the media in seconds as defined by the sender
Poll open_period Integer Optional. Amount of time in seconds the poll will be active after creation
Location live_period Integer Optional. Time relative to the message sending date, during which the location can be updated; in seconds. For active live locations only.
MessageAutoDeleteTimerChanged message_auto_delete_time Integer New auto-delete time for messages in the chat; in seconds
VideoChatEnded duration Integer Video chat duration in seconds
ChatInviteLink subscription_period Integer Optional. The number of seconds the subscription will be active for before the next payment
ResponseParameters retry_after Integer Optional. In case of exceeding flood control, the number of seconds left to wait before the request can be repeated
InputMediaVideo duration Integer Optional. Video duration in seconds
InputMediaAnimation duration Integer Optional. Animation duration in seconds
InputMediaAudio duration Integer Optional. Duration of the audio in seconds
InputPaidMediaVideo duration Integer Optional. Video duration in seconds
InlineQueryResultGif gif_duration Integer Optional. Duration of the GIF in seconds
InlineQueryResultMpeg4Gif mpeg4_duration Integer Optional. Video duration in seconds
InlineQueryResultVideo video_duration Integer Optional. Video duration in seconds
InlineQueryResultAudio audio_duration Integer Optional. Audio duration in seconds
InlineQueryResultVoice voice_duration Integer Optional. Recording duration in seconds
InlineQueryResultLocation live_period Integer Optional. Period in seconds during which the location can be updated, should be between 60 and 86400, or 0x7FFFFFFF for live locations that can be edited indefinitely.
InputLocationMessageContent live_period Integer Optional. Period in seconds during which the location can be updated, should be between 60 and 86400, or 0x7FFFFFFF for live locations that can be edited indefinitely.

Check-list for PRs

  • Added .. versionadded:: NEXT.VERSION, .. versionchanged:: NEXT.VERSION, .. deprecated:: NEXT.VERSION or .. versionremoved:: NEXT.VERSION to the docstrings for user facing changes (for methods/class descriptions, arguments and attributes)
  • Created new or adapted existing unit tests
  • Documented code changes according to the CSI standard <https://standards.mousepawmedia.com/en/stable/csi.html>__
  • Checked the Stability Policy <https://docs.python-telegram-bot.org/stability_policy.html>_ in case of deprecations or changes to documented behavior

If the PR contains API changes (otherwise, you can ignore this passage)

  • Checked the Bot API specific sections of the Stability Policy <https://docs.python-telegram-bot.org/stability_policy.html>_

  • Created a PR to remove functionality deprecated in the previous Bot API release (see here <https://docs.python-telegram-bot.org/en/stable/stability_policy.html#case-2>_)

  • If relevant:

    • Added or updated documentation for the changed class(es) and/or method(s)

github-actions bot pushed a commit that referenced this pull request Apr 9, 2025
@aelkheir
Copy link
Member Author

aelkheir commented Apr 9, 2025

One thought before i proceed with the rest of the classes.

Both attributes and arguments should only accept timedeltas in the long run. #4575 (comment)

why deprecate class args, i think it would be convenient to allow passing either 30 or timedelta(seconds=30). it would also be consistent with how bot methods work as of #4651. deprecating class attrs is sensible however.

@Bibo-Joshi
Copy link
Member

Hey, thanks for getting started so quickly:)
About your question: TelegramObject types are not really intended to be manually initialized by the user but rather by the de_json methods - that's in contrast to the bot methods. I view them as mere data storages. Note that the conversions from json to datetime or off of the TG classes also all happen in de_json and not in init. This approach can certainly be questioned in general (i.e. what would be a sensible interface for classes wrapping API types). In the current set-up, I'd see consistency as the more important argument 😅

@aelkheir
Copy link
Member Author

aelkheir commented Apr 9, 2025

Aha okay. but would it still be necessary to issue a separate deprecation warning in the init methods as was outlined in #4575 (comment) (very helpful summary btw 😄 ). one is already being issued on property access.

@Bibo-Joshi
Copy link
Member

I would say, so yes. Attribute access and passing the parameter are two independent operations, both of which will be affected :)

@aelkheir
Copy link
Member Author

aelkheir commented Apr 9, 2025

I see! I was thinking more from the perspective that

TelegramObject types are not really intended to be manually initialized by the user rather by the de_json.

whom are we intending to warn then

anyways I did include both warnings for completeness and the cases where users are manually initializing objects

@aelkheir aelkheir mentioned this pull request Apr 24, 2025
14 tasks
aelkheir added 14 commits May 16, 2025 01:23
- ChatFullInfo.slow_mode_delay.
- ChatFullInfo.message_auto_delete_time.

Conflicts:
	telegram/_chatfullinfo.py
	tests/test_chatfullinfo.py
	tests/test_official/exceptions.py
Conflicts:
	tests/test_chatfullinfo.py
Conflicts:
	tests/test_chatfullinfo.py
This includes ``duration`` param of the following:
- Animation               - Audio
- Video                   - VideoNote
- Voice                   - PaidMediaPreview
- VideoChatEnded          - InputMediaVideo
- InputMediaAnimation     - InputMediaAudio
- InputPaidMediaVideo
Conflicts:
	tests/test_paidmedia.py
	tests/test_videochat.py
- InlineQueryResultGif.gif_duration
- InlineQueryResultMpeg4Gif.mpeg4_duration
- InlineQueryResultVideo.video_duration
- InlineQueryResultAudio.audio_duration
- InlineQueryResultVoice.voice_duration
- InlineQueryResultLocation.live_period
- Video.start_timestamp
- Poll.open_period
- Location.live_period
- MessageAutoDeleteTimerChanged.message_auto_delete_time
- ChatInviteLink.subscription_period
- InputLocationMessageContent.live_period
This also discovered `Bot.get_updates.timeout` thas was
missed (?) in #4651.
@aelkheir aelkheir force-pushed the feature/4575-timedelta-classes branch from bc3cf6d to 466e0b0 Compare May 17, 2025 23:35
@aelkheir
Copy link
Member Author

Almost ready for a review. just a few things to discuss.

  • Not sure if I should follow the deprecation process for field ResponseParameter.retry_after which maps to PTB's class RetryAfter(TelegramError). a suggestion is to let self.retry_after continue being and int. and only alter the message of the error with a timedelta value derived from selr.retry_after. So it prints:
    Flood control exceeded. Retry in 0:08:20.
    instead of what it currently prints:
    Flood control exceeded. Retry in 500 seconds
    Reason being that it's underling int type is not user-facing. and also not to break older pickles of the class.
  • Automatic detection of time periods in test_official works great (only needed a few adjustments). Anyway it also detected Bot.get_updates.timeout (getUpdates) which i think was missed in #4651 (?). I have currently adjusted Bot.get_updates.timeout but without altering the signature of other methods that call it (i.e Updater.[_]start_polling) which also have a timeout parameter.

Checks are really mad, will debug tomorrow :')

@Bibo-Joshi
Copy link
Member

Hey, thanks for the updates - looks like you've spend quite some energy in this, awesome :) I haven't managed to look at the code yet, so at least giving my 2cts on your comments

  • RetryAfter: I'd personally also like a timedelta here, nice catch actually! In terms on interface changes I don't see a reason not to do that. If updating the pickle behavior becomes difficult I'd be okay with staying with an integer. Mind that pickle behavior doesn't need to be backward compatibile - we explicitly exclude that in the stability policy and I guess picklung exceptions is a rather small use case …
  • thanks for also catching get_updates. Even though I'm not quite sure why I skipped it, I think it may be because I also skipped all the other timeout parameters. Accepting timedelta for them as well might make sense as well but here the issue would be that the abstract BaseRquest.do_request should then also broaden the signature. So I'd postpones that discussion for now. for get_updates.timeout I'd say you can feel free to update also the signatures that forward the value to get_updates.timeout :)

@harshil21 harshil21 added 🛠 refactor change type: refactor 🔌 enhancement pr description: enhancement labels May 22, 2025
Copy link
Member

@harshil21 harshil21 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a simple change, but we have to change so many files and tests for this :(

PR is mostly good, I'm just double checking things-

@harshil21 harshil21 requested a review from Copilot May 22, 2025 07:53
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR migrates numerous time-based attributes from plain integer seconds to accept and return datetime.timedelta alongside int values, enhancing API ergonomics and future-proofing duration handling.

  • Introduces TimePeriod type alias, parse_period_arg input parsing, and get_timedelta_value output normalization for all affected duration/live-period fields.
  • Updates docstrings with .. versionchanged:: and .. deprecated:: annotations, adds property getters, and implements to_dict/de_json to correctly serialize timedelta.
  • Adjusts get_updates in the Bot API to allow timeout as timedelta.

Reviewed Changes

Copilot reviewed 52 out of 52 changed files in this pull request and generated 2 comments.

File Description
telegram/_files/inputmedia.py Added parse_period_arg and get_timedelta_value usage for duration, input media; enhanced to_dict.
telegram/_inline/inlinequeryresultvoice.py Converted voice_duration to TimePeriod, added property and to_dict serialization.
telegram/_files/voice.py Updated Voice.duration to accept timedelta, added de/serialization and property.
Comments suppressed due to low confidence (3)

telegram/_files/inputmedia.py:303

  • The start_timestamp field is not passed through parse_period_arg, so providing a timedelta won’t populate _start_timestamp. Replace this with self._start_timestamp = parse_period_arg(start_timestamp) and update __slots__ accordingly.
self.start_timestamp: Optional[int] = start_timestamp

telegram/_files/inputmedia.py:212

  • InputPaidMediaVideo defines _duration and _start_timestamp but does not override to_dict, so these fields won’t be serialized. Add a to_dict method similar to other media classes to include duration and start_timestamp in the output.
class InputPaidMediaVideo(InputPaidMedia):

telegram/_files/inputmedia.py:34

  • [nitpick] There are no tests for InputPaidMediaVideo handling of timedelta vs. int. Please add unit tests to verify parsing, property output, and to_dict serialization for both input types.
parse_period_arg, parse_sequence_arg

aelkheir added 3 commits May 29, 2025 00:30
Failure at `src/telegram/_utils/datetime.py:38:0` is not related to this
PR. Not sure why it's being reported just now (CI only). It's not caught
locally (with fresh precommit cache).
@aelkheir
Copy link
Member Author

aelkheir commented Jun 1, 2025

7d93eb5

Failure at src/telegram/_utils/datetime.py:38:0 is not related to this
PR.

that one's related :). I meant /stars/staramount.py:19

@aelkheir aelkheir requested review from Bibo-Joshi and harshil21 June 1, 2025 16:35
@Bibo-Joshi Bibo-Joshi linked an issue Jun 2, 2025 that may be closed by this pull request
Copy link
Member

@Bibo-Joshi Bibo-Joshi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for this nice PR! Very clean code changes IMO. I've left a few comments, but I think only a few of them are substantial. :) I hope that I haven't doubled any earier comments of harshil 😅

Comment on lines 643 to 647
# Converting to int here is neccassry in some cases where Bot API returns
# 'BadRquest' when expecting integers (e.g. Video.duration)
out[key] = (
int(seconds) if (seconds := value.total_seconds()).is_integer() else seconds
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting catch! I'm wondering if there are cases were a float is accepted as time duration. that would be a hard-to-catch bug, if the user passes a float and the int-version is passed by TG 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I meant InputMediaVideo there, will update comment.

Ah yes unfortunately :( and to make matters worse they're Business related
InputStoryContentVideo.[duration/cover_frame_timestamp]
InputProfilePhotoAnimated.main_frame_timestamp

would it suffice if we tested whether telegram returns ok if provided integers instead of floats, plus a code comment?

this also reminded me one these classes is using it's own float -> timedelta parsing logic. 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, thanks for the info! Pointing out the currently known use cases should suffice IMO. If you want to, adding a special test case for one of them would be plenty - you could add a comment along the lines "this applies also to other classes e.g. …, but we test it only for one as this suffices to ensure that the logic works" :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm we can't really test this in the test suite, because it requires a Telegram Business account. I can prepare a minimal example to send an actual request if a team member has access to a test business account.
@Poolitzer (or others) would someone be able help verify this?

For context I want to ensure that making a request like this:

await def post_story(
    self,
    "business_connection_id",
    content=InputStoryContentVideo(
        video=video.read_bytes(),
        duration=10.0, # or datetime.timedelta(seconds=10)
        cover_frame_timestamp=5.0, # or datetime.timedelta(seconds=5)
        is_animation=False,
    ),
)

to telegram does not raise a BadRequest for invalid duration or cover_frame_timestamp. and a similar request for bot.set_business_account_profile_photo with photo=InputProfilePhotoAnimated.

I can run the example or provide it so that someone could :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mh, I don't have a business account and I fear that setting up something for them in the tests would be a hassle. Alternatively, you can mock the tg response by mocking httpx.post and returning an error if the payload contains a float

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha no problem!
I'm just thinking mocking the request would be of little use, because we don't know the actual Telegram behavior (throw, or not throw)

mocking httpx.post and returning an error if the payload contains a float

Also in the case of two methods above, passing a float to Telegram should be successful, it's integers that might (or might not) throw BadRequest errors.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do have a business account, is there an example you'd like me to run?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also in the case of two methods above, passing a float to Telegram should be successful, it's integers that might (or might not) throw BadRequest errors.

okay, the other way around :D

I'm just thinking mocking the request would be of little use, because we don't know the actual Telegram behavior (throw, or not throw)

That's true, it's not perfect. What we would achieve with this is that we avoid regressions in what we think is the right implementation :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay, the other way around :D

haha thought i was the lost one for a sec xD

here is the test bot.

float_periods_bot.py
#!/usr/bin/env python

import asyncio
import datetime
import logging

from telegram import Update
from telegram._files._inputstorycontent import InputStoryContentVideo
from telegram._files.inputprofilephoto import InputProfilePhotoAnimated
from telegram.constants import StoryLimit
from telegram.ext import Application, CommandHandler, ContextTypes
from tests.auxil.files import data_file

logging.basicConfig(
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
)
logging.getLogger("httpx").setLevel(logging.DEBUG)

logger = logging.getLogger(__name__)


async def post_story(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    for duration, cover_timestamp in [
        (4.0, 2.0),
        (4, 2),
        (datetime.timedelta(seconds=4.0), datetime.timedelta(seconds=2.0)),
    ]:
        content = InputStoryContentVideo(
            video=data_file("telegram.mp4").read_bytes(),
            duration=duration,
            cover_frame_timestamp=cover_timestamp,
        )
        logging.info("Posting story with arguments of type %s", type(duration))
        logging.info("content: %s", content.to_dict())

        await context.bot.post_story(
            "BUSINESS_CONNECTION_ID",
            content=content,
            active_period=StoryLimit.ACTIVITY_SIX_HOURS,
        )
        await asyncio.sleep(0.5)


async def set_profile_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Send a message when the command /help is issued."""
    for timestamp in [
        1.0,
        1,
        datetime.timedelta(seconds=1),
    ]:
        photo = InputProfilePhotoAnimated(
            animation=data_file("telegram2.mp4").read_bytes(),
            main_frame_timestamp=1.0,
        )
        logging.info("Setting profile photo with arguments of type %s", type(timestamp))
        logging.info("photo %s", photo.to_dict())
        await context.bot.set_business_account_profile_photo(
            "BUSINESS_CONNECTION_ID",
            photo=photo,
            is_public=False,
        )
        await asyncio.sleep(0.5)


def main() -> None:
    """Start the bot."""
    application = Application.builder().token("TOKEN").build()

    application.add_handler(CommandHandler("post_story", post_story))
    application.add_handler(CommandHandler("set_profile_photo", set_profile_photo))

    application.run_polling(allowed_updates=Update.ALL_TYPES)


if __name__ == "__main__":
    main()

thank you so, very much!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as discussed internally here https://t.me/c/1494805131/45463, An actual request for post_story ran successfully with integers in place of floats.

Copy link
Member

@harshil21 harshil21 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the work on this PR! I left more comments, mainly for the core logic

Copy link
Member Author

@aelkheir aelkheir left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apologies for the late updates

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

without these modifications the test hangs..
not sure if i missed something inside Updater.
if you notice something please note it.

Copy link
Member

@Bibo-Joshi Bibo-Joshi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for the updates!

Comment on lines 643 to 647
# Converting to int here is neccassry in some cases where Bot API returns
# 'BadRquest' when expecting integers (e.g. Video.duration)
out[key] = (
int(seconds) if (seconds := value.total_seconds()).is_integer() else seconds
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, thanks for the info! Pointing out the currently known use cases should suffice IMO. If you want to, adding a special test case for one of them would be plenty - you could add a comment along the lines "this applies also to other classes e.g. …, but we test it only for one as this suffices to ensure that the logic works" :)

@aelkheir aelkheir added the 📋 pending-review work status: pending-review label Jun 28, 2025
Copy link
Member

@harshil21 harshil21 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@Bibo-Joshi Bibo-Joshi merged commit 979db09 into master Jun 29, 2025
32 checks passed
@Bibo-Joshi Bibo-Joshi deleted the feature/4575-timedelta-classes branch June 29, 2025 16:09
@Bibo-Joshi
Copy link
Member

Thank you very much for this contribution and especially the patience in the review process!

@harshil21 harshil21 removed the 📋 pending-review work status: pending-review label Jun 29, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🔌 enhancement pr description: enhancement 🛠 refactor change type: refactor
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Use datetime.timedelta to Represent Time Periods
3 participants