diff --git a/changes/unreleased/4861.HEoGVs2mYXWzqMahi6SEhV.toml b/changes/unreleased/4861.HEoGVs2mYXWzqMahi6SEhV.toml new file mode 100644 index 00000000000..c3ccdcf6061 --- /dev/null +++ b/changes/unreleased/4861.HEoGVs2mYXWzqMahi6SEhV.toml @@ -0,0 +1,5 @@ +features = "Added a two methods for BusinessOpeningHours" +[[pull_requests]] +uid = "4326" +author_uid = "Aweryc" +closes_threads = ["4194"] diff --git a/src/telegram/__main__.py b/src/telegram/__main__.py index 90e349eca5d..c685c0a389a 100644 --- a/src/telegram/__main__.py +++ b/src/telegram/__main__.py @@ -28,7 +28,7 @@ def _git_revision() -> Optional[str]: try: - output = subprocess.check_output( + output = subprocess.check_output( # noqa: S603 ["git", "describe", "--long", "--tags"], stderr=subprocess.STDOUT ) except (subprocess.SubprocessError, OSError): diff --git a/src/telegram/_business.py b/src/telegram/_business.py index e9001d4bdf9..625347b7b0b 100644 --- a/src/telegram/_business.py +++ b/src/telegram/_business.py @@ -21,6 +21,7 @@ import datetime as dtm from collections.abc import Sequence from typing import TYPE_CHECKING, Optional +from zoneinfo import ZoneInfo from telegram._chat import Chat from telegram._files.location import Location @@ -28,7 +29,7 @@ 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.datetime import extract_tzinfo_from_defaults, from_timestamp, verify_timezone from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -448,7 +449,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: @@ -570,6 +571,90 @@ def __init__( self._freeze() + def get_opening_hours_for_day( + self, date: dtm.date, time_zone: Optional[ZoneInfo] = None + ) -> tuple[tuple[dtm.datetime, dtm.datetime], ...]: + """Returns the opening hours intervals for a specific day as datetime objects. + + .. versionadded:: NEXT.VERSION + + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their + :attr:`time_zone_name` and :attr:`opening_hours` are equal. + Args: + date (:obj:`datetime.date`): The date to get opening hours for. + Only the weekday component + is used to determine matching opening intervals. + time_zone (:obj:`zoneinfo.ZoneInfo`, optional): Timezone to use for the returned + datetime objects. If not specified, the returned datetime objects + will be timezone-naive. + + 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 = [] + + for interval in self.opening_hours: + int_open = interval.opening_time + int_close = interval.closing_time + if int_open[0] == week_day: + res.append( + ( + dtm.datetime( + year=date.year, + month=date.month, + day=date.day, + hour=int_open[1], + minute=int_open[2], + tzinfo=verify_timezone(time_zone) if time_zone else None, + ), + dtm.datetime( + year=date.year, + month=date.month, + day=date.day, + hour=int_close[1], + minute=int_close[2], + tzinfo=verify_timezone(time_zone) if time_zone else None, + ), + ) + ) + + return tuple(res) + + def is_open(self, dt: dtm.datetime) -> bool: + """Check if the business is open at the specified datetime. + + .. versionadded:: NEXT.VERSION + + Args: + dt (:obj:`datetime.datetime`): The datetime to check. + If timezone-aware, the check will be performed in that timezone. + If timezone-naive, the check will be performed in the + timezone specified by :attr:`time_zone_name`. + Returns: + :obj:`bool`: True if the business is open at the specified time, False otherwise. + """ + + if dt.tzinfo is None: + dt_utc = dt + else: + dt_utc = dt.astimezone(verify_timezone(ZoneInfo(self.time_zone_name))) + + weekday = dt_utc.weekday() + minute_of_week = weekday * 1440 + dt_utc.hour * 60 + dt_utc.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`.""" diff --git a/src/telegram/_utils/datetime.py b/src/telegram/_utils/datetime.py index a0cc126dd94..772764d671c 100644 --- a/src/telegram/_utils/datetime.py +++ b/src/telegram/_utils/datetime.py @@ -31,10 +31,9 @@ import datetime as dtm import os import time +import zoneinfo 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 @@ -230,6 +229,32 @@ def _datetime_to_float_timestamp(dt_obj: dtm.datetime) -> float: return dt_obj.timestamp() +def verify_timezone( + tz: Union[dtm.tzinfo, zoneinfo.ZoneInfo], +) -> Optional[Union[zoneinfo.ZoneInfo, dtm.tzinfo]]: + """ + Verifies that the given timezone is a valid timezone. + """ + + if isinstance(tz, (dtm.tzinfo, zoneinfo.ZoneInfo)): + return tz + + try: + return zoneinfo.ZoneInfo(tz) + except (TypeError, ValueError) as e: + raise zoneinfo.ZoneInfoNotFoundError( + f"No time zone found with key {tz}. " + f"Make sure to use a valid time zone name and " + f"correct install tzdata (https://pypi.org/project/tzdata/)" + ) from e + except zoneinfo.ZoneInfoNotFoundError as err: + raise zoneinfo.ZoneInfoNotFoundError( + f"No time zone found with key {tz}. " + f"Make sure to use a valid time zone name and " + f"correct install tzdata (https://pypi.org/project/tzdata/)" + ) from err + + def get_timedelta_value( value: Optional[dtm.timedelta], attribute: str ) -> Optional[Union[int, dtm.timedelta]]: diff --git a/tests/_utils/test_datetime.py b/tests/_utils/test_datetime.py index 06d252a64a2..0cddd9b1421 100644 --- a/tests/_utils/test_datetime.py +++ b/tests/_utils/test_datetime.py @@ -23,6 +23,7 @@ import pytest from telegram._utils import datetime as tg_dtm +from telegram._utils.datetime import verify_timezone from telegram.ext import Defaults # sample time specification values categorised into absolute / delta / time-of-day @@ -168,7 +169,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): @@ -193,6 +194,59 @@ 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_with_zoneinfo_object(self): + """Test with a valid zoneinfo.ZoneInfo object.""" + tz = zoneinfo.ZoneInfo("Europe/Paris") + result = verify_timezone(tz) + assert result == tz + + def test_with_datetime_tzinfo(self): + """Test with a datetime.tzinfo object.""" + + class CustomTZ(dtm.tzinfo): + def utcoffset(self, dt): + return dtm.timedelta(hours=2) + + def dst(self, dt): + return dtm.timedelta(0) + + tz = CustomTZ() + result = verify_timezone(tz) + assert result == tz + + def test_with_valid_timezone_string(self): + """Test with a valid timezone string.""" + tz = "Asia/Tokyo" + result = verify_timezone(tz) + assert isinstance(result, zoneinfo.ZoneInfo) + assert str(result) == "Asia/Tokyo" + + def test_with_none(self): + """Test with None input.""" + with pytest.raises(zoneinfo.ZoneInfoNotFoundError, match="No time zone found"): + verify_timezone(None) + + def test_with_invalid_timezone_string(self): + """Test with an invalid timezone string.""" + with pytest.raises(zoneinfo.ZoneInfoNotFoundError, match="No time zone found"): + verify_timezone("Invalid/Timezone") + + def test_with_empty_string(self): + """Test with empty string input.""" + with pytest.raises(zoneinfo.ZoneInfoNotFoundError, match="No time zone found"): + verify_timezone("") + + def test_with_non_timezone_object(self): + """Test with an object that isn't a timezone.""" + with pytest.raises(zoneinfo.ZoneInfoNotFoundError, match="No time zone found"): + verify_timezone(123) # integer + with pytest.raises(zoneinfo.ZoneInfoNotFoundError, match="No time zone found"): + verify_timezone({"key": "value"}) # dict + with pytest.raises(zoneinfo.ZoneInfoNotFoundError, match="No time zone found"): + verify_timezone([]) # empty list + + @pytest.mark.parametrize( ("arg", "timedelta_result", "number_result"), [ diff --git a/tests/test_business_classes.py b/tests/test_business_classes.py index b1ba4ced08a..e9373118bb9 100644 --- a/tests/test_business_classes.py +++ b/tests/test_business_classes.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 zoneinfo import ZoneInfo import pytest @@ -554,3 +555,193 @@ def test_equality(self): assert boh1 != boh3 assert hash(boh1) != hash(boh3) + + +class TestBusinessOpeningHoursGetOpeningHoursForDayWithoutRequest: + @pytest.fixture + def sample_opening_hours(self): + # Monday 8am-8:30pm (480-1230) + # Tuesday 24 hours (1440-2879) + # Sunday 12am-11:58pm (8640-10078) + intervals = [ + BusinessOpeningHoursInterval(480, 1230), # Monday 8am-8:30pm + BusinessOpeningHoursInterval(1440, 2879), # Tuesday 24 hours + BusinessOpeningHoursInterval(8640, 10078), # Sunday 12am-11:58pm + ] + return BusinessOpeningHours(time_zone_name="UTC", opening_hours=intervals) + + def test_monday_opening_hours(self, sample_opening_hours): + # Test for Monday + test_date = dtm.date(2023, 11, 6) # Monday + time_zone = ZoneInfo("UTC") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 6, 8, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 6, 20, 30, tzinfo=time_zone), + ), + ) + + assert result == expected + + def test_tuesday_24_hours(self, sample_opening_hours): + # Test for Tuesday (24 hours) + test_date = dtm.date(2023, 11, 7) # Tuesday + time_zone = ZoneInfo("UTC") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 7, 0, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 7, 23, 59, tzinfo=time_zone), + ), + ) + + assert result == expected + + def test_sunday_opening_hours(self, sample_opening_hours): + # Test for Sunday + test_date = dtm.date(2023, 11, 12) # Sunday + time_zone = ZoneInfo("UTC") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 12, 0, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 12, 23, 58, tzinfo=time_zone), + ), + ) + + assert result == expected + + def test_day_with_no_opening_hours(self, sample_opening_hours): + # Test for Wednesday (no opening hours defined) + test_date = dtm.date(2023, 11, 8) # Wednesday + time_zone = ZoneInfo("UTC") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + assert result == () + + def test_multiple_intervals_same_day(self): + # Test with multiple intervals on the same day + intervals = [ + BusinessOpeningHoursInterval(480, 720), # Monday 8am-12pm + BusinessOpeningHoursInterval(900, 1230), # Monday 3pm-8:30pm + ] + opening_hours = BusinessOpeningHours(time_zone_name="UTC", opening_hours=intervals) + + test_date = dtm.date(2023, 11, 6) # Monday + time_zone = ZoneInfo("UTC") + result = opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 6, 8, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 6, 12, 0, tzinfo=time_zone), + ), + ( + dtm.datetime(2023, 11, 6, 15, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 6, 20, 30, tzinfo=time_zone), + ), + ) + + assert result == expected + + def test_timezone_conversion(self, sample_opening_hours): + # Test that timezone is properly applied + test_date = dtm.date(2023, 11, 6) # Monday + time_zone = ZoneInfo("America/New_York") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 6, 8, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 6, 20, 30, tzinfo=time_zone), + ), + ) + + assert result == expected + assert result[0][0].tzinfo == time_zone + assert result[0][1].tzinfo == time_zone + + def test_no_timezone_provided(self, sample_opening_hours): + # Test when no timezone is provided + test_date = dtm.date(2023, 11, 6) # Monday + result = sample_opening_hours.get_opening_hours_for_day(test_date) + + expected = ( + ( + dtm.datetime(2023, 11, 6, 8, 0, tzinfo=None), + dtm.datetime(2023, 11, 6, 20, 30, tzinfo=None), + ), + ) + + assert result == expected + + +class TestBusinessOpeningHoursIsOpenWithoutRequest: + @pytest.fixture + def sample_opening_hours(self): + # Monday 8am-8:30pm (480-1230) + # Tuesday 24 hours (1440-2879) + # Sunday 12am-11:58pm (8640-10078) + intervals = [ + BusinessOpeningHoursInterval(480, 1230), # Monday 8am-8:30pm UTC + BusinessOpeningHoursInterval(1440, 2879), # Tuesday 24 hours UTC + BusinessOpeningHoursInterval(8640, 10078), # Sunday 12am-11:58pm UTC + ] + return BusinessOpeningHours(time_zone_name="UTC", opening_hours=intervals) + + def test_is_open_during_business_hours(self, sample_opening_hours): + # Monday 10am UTC (within 8am-8:30pm) + dt = dtm.datetime(2023, 11, 6, 10, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is True + + def test_is_open_at_opening_time(self, sample_opening_hours): + # Monday exactly 8am UTC + dt = dtm.datetime(2023, 11, 6, 8, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is True + + def test_is_closed_at_closing_time(self, sample_opening_hours): + # Monday exactly 8:30pm UTC (closing time is exclusive) + dt = dtm.datetime(2023, 11, 6, 20, 30, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is False + + def test_is_closed_outside_business_hours(self, sample_opening_hours): + # Monday 7am UTC (before opening) + dt = dtm.datetime(2023, 11, 6, 7, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is False + + def test_is_open_24h_day(self, sample_opening_hours): + # Tuesday 3am UTC (24h opening) + dt = dtm.datetime(2023, 11, 7, 3, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is True + + def test_is_closed_on_day_with_no_hours(self, sample_opening_hours): + # Wednesday (no opening hours) + dt = dtm.datetime(2023, 11, 8, 12, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is False + + def test_timezone_conversion(self, sample_opening_hours): + # Monday 10am UTC is 6am EDT (should be closed) + dt = dtm.datetime(2023, 11, 6, 2, 30, tzinfo=ZoneInfo("America/New_York")) + assert sample_opening_hours.is_open(dt) is False + + # Monday 10am EDT is 2pm UTC (should be open) + dt = dtm.datetime(2023, 11, 6, 10, 0, tzinfo=ZoneInfo("America/New_York")) + assert sample_opening_hours.is_open(dt) is True + + def test_naive_datetime_uses_business_timezone(self, sample_opening_hours): + # Naive datetime - should be interpreted as UTC (business timezone) + dt = dtm.datetime(2023, 11, 6, 10, 0) # 10am naive + assert sample_opening_hours.is_open(dt) is True + + def test_boundary_conditions(self, sample_opening_hours): + # Sunday 11:58pm UTC (should be open) + dt = dtm.datetime(2023, 11, 12, 23, 57, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is True + + # Sunday 11:59pm UTC (should be closed) + dt = dtm.datetime(2023, 11, 12, 23, 59, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is False
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: