Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b175dab
Convenience Functionality for BusinessOpeningHours
Aweryc Jul 14, 2025
49aa14b
Merge pull request #1 from Aweryc/convenience-functionalty-BOH
Aweryc Jul 14, 2025
0cd3963
Merge branch 'master' into master
Aweryc Jul 14, 2025
e3132e9
Update changes/unreleased/48XX._.toml
Aweryc Jul 15, 2025
fa4e6cd
Update src/telegram/_utils/datetime.py
Aweryc Jul 15, 2025
9c183e7
Update src/telegram/_utils/datetime.py
Aweryc Jul 15, 2025
0b836cf
Update tests/_utils/test_datetime.py
Aweryc Jul 15, 2025
5a91e66
Rename 48XX._.toml to 4861.HEoGVs2mYXWzqMahi6SEhV.toml
Aweryc Jul 15, 2025
869748e
Merge branch 'python-telegram-bot:master' into master
Aweryc Jul 17, 2025
83cb615
Update pyproject.toml
Aweryc Jul 17, 2025
73def0a
Revert enum.py
Aweryc Jul 17, 2025
0a70348
Proper re-raise zoneinfo.ZoneInfoNotFoundError
Aweryc Jul 17, 2025
99634d0
Update datetime.py
Aweryc Jul 17, 2025
86540bf
Rename change file .toml
Aweryc Jul 17, 2025
6ff094d
revert pyproject.toml
Aweryc Jul 17, 2025
c63f1eb
remove TelegramError
Aweryc Jul 18, 2025
a40a573
Fix a code and tests
Aweryc Jul 19, 2025
80ebca4
Merge branch 'master' into convenience-functionalty-BOH
Aweryc Jul 19, 2025
264f6f5
Merge pull request #2 from Aweryc/convenience-functionalty-BOH
Aweryc Jul 19, 2025
6bbc7d6
Merge branch 'python-telegram-bot:master' into master
Aweryc Jul 20, 2025
0732859
Fix a code and tests
Aweryc Jul 21, 2025
4c8995e
Merge branch 'convenience-functionalty-BOH'
Aweryc Jul 21, 2025
74e9d13
Merge remote-tracking branch 'origin/master'
Aweryc Jul 21, 2025
60341e4
Fix a code and tests from comments GitHub PR discussion
Aweryc Aug 17, 2025
4c7bd16
Merge branch 'master' into convenience-functionalty-BOH
Aweryc Aug 17, 2025
ac53a8a
Merge branch 'master' into master
Aweryc Aug 17, 2025
4a5876c
Update changes/unreleased/4861.HEoGVs2mYXWzqMahi6SEhV.toml
Aweryc Aug 23, 2025
549b5a0
Apply suggestions from code review
Aweryc Aug 23, 2025
1a2c9da
revert changes datetime.py
Aweryc Aug 23, 2025
5285ab0
Merge branch 'master' into aweryc-master
Bibo-Joshi Sep 13, 2025
c0d46a6
Implement Review changes + plus some smaller changes
Bibo-Joshi Sep 13, 2025
fa8633f
fix failing test
Bibo-Joshi Sep 13, 2025
77a64b3
Remove superflous path in get_zone_info
Bibo-Joshi Sep 13, 2025
3377c3e
review
Bibo-Joshi Sep 24, 2025
bf0bab8
Update tests/test_business_classes.py
Bibo-Joshi Sep 24, 2025
45a5bec
ruff
Bibo-Joshi Sep 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions changes/unreleased/4861.HEoGVs2mYXWzqMahi6SEhV.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
features = """
Add convenience methods for ``BusinessOpeningHours``:

* ``get_opening_hours_for_day``: returns the opening hours applicable for a specific date
* ``is_open``: indicates whether the business is open at the specified date and time.
"""
[[pull_requests]]
uid = "4861"
author_uid = "Aweryc"
closes_threads = ["4194"]
118 changes: 113 additions & 5 deletions src/telegram/_business.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,24 @@

import datetime as dtm
from collections.abc import Sequence
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, Optional, Union
from zoneinfo import ZoneInfo

from telegram._chat import Chat
from telegram._files.location import Location
from telegram._files.sticker import Sticker
from telegram._telegramobject import TelegramObject
from telegram._user import User
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,
)
from telegram._utils.datetime import (
extract_tzinfo_from_defaults,
from_timestamp,
get_zone_info,
)
from telegram._utils.types import JSONDict

if TYPE_CHECKING:
Expand Down Expand Up @@ -449,7 +458,7 @@ class BusinessOpeningHoursInterval(TelegramObject):

Examples:
A day has (24 * 60 =) 1440 minutes, a week has (7 * 1440 =) 10080 minutes.
Starting the the minute's sequence from Monday, example values of
Starting the minute's sequence from Monday, example values of
:attr:`opening_minute`, :attr:`closing_minute` will map to the following day times:

* Monday - 8am to 8:30pm:
Expand Down Expand Up @@ -552,7 +561,7 @@ class BusinessOpeningHours(TelegramObject):
time intervals describing business opening hours.
"""

__slots__ = ("opening_hours", "time_zone_name")
__slots__ = ("_cached_zone_info", "opening_hours", "time_zone_name")

def __init__(
self,
Expand All @@ -567,10 +576,109 @@ def __init__(
opening_hours
)

self._cached_zone_info: Optional[ZoneInfo] = None

self._id_attrs = (self.time_zone_name, self.opening_hours)

self._freeze()

@property
def _zone_info(self) -> ZoneInfo:
if self._cached_zone_info is None:
self._cached_zone_info = get_zone_info(self.time_zone_name)
return self._cached_zone_info

def get_opening_hours_for_day(
self, date: dtm.date, time_zone: Union[dtm.tzinfo, str, None] = None
) -> tuple[tuple[dtm.datetime, dtm.datetime], ...]:
"""Returns the opening hours intervals for a specific day as datetime objects.

.. versionadded:: NEXT.VERSION

Args:
date (:obj:`datetime.date`): The date to get opening hours for.
time_zone (:obj:`datetime.tzinfo` | :obj:`str`, optional): Timezone to use for the
returned datetime objects. If not specified, then :attr:`time_zone_name` be used.

Returns:
tuple[tuple[:obj:`datetime.datetime`, :obj:`datetime.datetime`], ...]:
A tuple of datetime pairs representing opening and closing times for the specified day.
Each pair consists of ``(opening_time, closing_time)``.
Returns an empty tuple if there are no opening hours for the given day.
"""

week_day = date.weekday()
res = []
if isinstance(time_zone, str):
tz_target: dtm.tzinfo = get_zone_info(time_zone)
elif time_zone is None:
tz_target = self._zone_info
else:
tz_target = time_zone

for interval in self.opening_hours:
int_open = interval.opening_time
int_close = interval.closing_time

if int_open[0] != week_day:
continue

# To get the correct localization, we first need to create the dtm object in
# self.time_zone_name, then convert it to the target timezone. We could check if
# self._zone_info == tz_target and skip the conversion, but it's not worth the added
# complexity.
result_int_open = dtm.datetime(
Comment thread
Bibo-Joshi marked this conversation as resolved.
year=date.year,
month=date.month,
day=date.day,
hour=int_open[1],
minute=int_open[2],
tzinfo=self._zone_info,
).astimezone(tz_target)

result_int_close = dtm.datetime(
year=date.year,
month=date.month,
day=date.day,
hour=int_close[1],
minute=int_close[2],
tzinfo=self._zone_info,
).astimezone(tz_target)

res.append((result_int_open, result_int_close))

# The sorting is currently an implementation detail
return tuple(sorted(res, key=lambda x: x[0]))

def is_open(self, datetime: dtm.datetime) -> bool:
"""Check if the business is open at the specified datetime.

.. versionadded:: NEXT.VERSION

Args:
datetime (:obj:`datetime.datetime`): The datetime to check.
If the object is timezone-naive, it is assumed to be in the
timezone specified by :attr:`time_zone_name`.

Returns:
:obj:`bool`: True if the business is open at the specified time, False otherwise.
"""

datetime_in_native_tz = (
datetime.replace(tzinfo=self._zone_info) if datetime.tzinfo is None else datetime
).astimezone(self._zone_info)
minute_of_week = (
datetime_in_native_tz.weekday() * 1440
+ datetime_in_native_tz.hour * 60
+ datetime_in_native_tz.minute
)

for interval in self.opening_hours:
if interval.opening_minute <= minute_of_week < interval.closing_minute:
return True

return False

@classmethod
def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "BusinessOpeningHours":
"""See :meth:`telegram.TelegramObject.de_json`."""
Expand Down
16 changes: 16 additions & 0 deletions src/telegram/_utils/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import datetime as dtm
import os
import time
import zoneinfo
from typing import TYPE_CHECKING, Optional, Union

from telegram._utils.warnings import warn
Expand Down Expand Up @@ -231,6 +232,21 @@ def _datetime_to_float_timestamp(dt_obj: dtm.datetime) -> float:
return dt_obj.timestamp()


def get_zone_info(tz: str) -> zoneinfo.ZoneInfo:
"""Wrapper around the `ZoneInfo` constructor with slightly more helpful error message
in case tzdata is not installed.
"""
try:
return zoneinfo.ZoneInfo(tz)
except zoneinfo.ZoneInfoNotFoundError as err:
raise zoneinfo.ZoneInfoNotFoundError(
f"No time zone found with key {tz}. "
"Make sure to use a valid time zone name and "
f"correctly install the tzdata (https://pypi.org/project/tzdata/) package if "
"your system does not provide the time zone data."
) from err


def get_timedelta_value(
value: Optional[dtm.timedelta], attribute: str
) -> Optional[Union[int, dtm.timedelta]]:
Expand Down
23 changes: 21 additions & 2 deletions tests/_utils/test_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import pytest

from telegram._utils import datetime as tg_dtm
from telegram._utils.datetime import get_zone_info
from telegram.ext import Defaults

# sample time specification values categorised into absolute / delta / time-of-day
Expand Down Expand Up @@ -138,7 +139,10 @@ def test_to_float_timestamp_time_of_day_timezone(self, timezone):
# of an xpass when the test is run in a timezone with the same UTC offset
ref_datetime = dtm.datetime(1970, 1, 1, 12)
utc_offset = timezone.utcoffset(ref_datetime)
ref_t, time_of_day = tg_dtm._datetime_to_float_timestamp(ref_datetime), ref_datetime.time()
ref_t, time_of_day = (
tg_dtm._datetime_to_float_timestamp(ref_datetime),
ref_datetime.time(),
)
aware_time_of_day = tg_dtm.localize(ref_datetime, timezone).timetz()

# first test that naive time is assumed to be utc:
Expand Down Expand Up @@ -168,7 +172,7 @@ def test_to_timestamp(self):
assert tg_dtm.to_timestamp(i) == int(tg_dtm.to_float_timestamp(i)), f"Failed for {i}"

def test_to_timestamp_none(self):
# this 'convenience' behaviour has been left left for backwards compatibility
# this 'convenience' behaviour has been left for backwards compatibility
assert tg_dtm.to_timestamp(None) is None

def test_from_timestamp_none(self):
Expand All @@ -193,6 +197,21 @@ def test_extract_tzinfo_from_defaults(self, tz_bot, bot, raw_bot):
assert tg_dtm.extract_tzinfo_from_defaults(bot) is None
assert tg_dtm.extract_tzinfo_from_defaults(raw_bot) is None

def test_get_zone_info_with_valid_timezone_string(self):
"""Test with a valid timezone string."""
tz = "Asia/Tokyo"
result = get_zone_info(tz)
assert isinstance(result, zoneinfo.ZoneInfo)
assert str(result) == "Asia/Tokyo"

def test_get_zone_info_with_invalid_timezone_string(self):
"""Test with an invalid timezone string."""
with pytest.raises(
zoneinfo.ZoneInfoNotFoundError,
match=r"No time zone found.*Invalid/Timezone.*install the tzdata",
):
get_zone_info("Invalid/Timezone")

@pytest.mark.parametrize(
("arg", "timedelta_result", "number_result"),
[
Expand Down
Loading