diff --git a/changes/unreleased/4861.HEoGVs2mYXWzqMahi6SEhV.toml b/changes/unreleased/4861.HEoGVs2mYXWzqMahi6SEhV.toml new file mode 100644 index 00000000000..b511be65416 --- /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"] \ No newline at end of file diff --git a/src/telegram/__main__.py b/src/telegram/__main__.py index 7d291b2ae1e..c685c0a389a 100644 --- a/src/telegram/__main__.py +++ b/src/telegram/__main__.py @@ -17,7 +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/]. # pylint: disable=missing-module-docstring -# ruff: noqa: T201, D100, S603, S607 +# ruff: noqa: T201, D100, S607 import subprocess import sys from typing import Optional @@ -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 dd055426654..7846955af7e 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 from telegram._utils.warnings import warn from telegram._utils.warnings_transition import ( @@ -494,7 +495,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: @@ -616,6 +617,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), + ), + dtm.datetime( + year=date.year, + month=date.month, + day=date.day, + hour=int_close[1], + minute=int_close[2], + tzinfo=verify_timezone(time_zone), + ), + ) + ) + + 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/_passport/encryptedpassportelement.py b/src/telegram/_passport/encryptedpassportelement.py index c231c51640b..65f88e7a69b 100644 --- a/src/telegram/_passport/encryptedpassportelement.py +++ b/src/telegram/_passport/encryptedpassportelement.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# flake8: noqa: E501 # A library that provides a Python interface to the Telegram Bot API # Copyright (C) 2015-2025 # Leandro Toledo de Souza diff --git a/src/telegram/_payment/stars/staramount.py b/src/telegram/_payment/stars/staramount.py index a8d61b2a118..c78a4aa9aba 100644 --- a/src/telegram/_payment/stars/staramount.py +++ b/src/telegram/_payment/stars/staramount.py @@ -16,7 +16,6 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -# pylint: disable=redefined-builtin """This module contains an object that represents a Telegram StarAmount.""" diff --git a/src/telegram/_utils/datetime.py b/src/telegram/_utils/datetime.py index 8e6ebdda1b4..57aa683c977 100644 --- a/src/telegram/_utils/datetime.py +++ b/src/telegram/_utils/datetime.py @@ -30,6 +30,7 @@ import contextlib import datetime as dtm import time +import zoneinfo from typing import TYPE_CHECKING, Optional, Union if TYPE_CHECKING: @@ -224,3 +225,25 @@ def _datetime_to_float_timestamp(dt_obj: dtm.datetime) -> float: if dt_obj.tzinfo is None: dt_obj = dt_obj.replace(tzinfo=dtm.timezone.utc) return dt_obj.timestamp() + + +def verify_timezone( + tz: Optional[Union[dtm.tzinfo, zoneinfo.ZoneInfo]], +) -> Optional[Union[zoneinfo.ZoneInfo, dtm.tzinfo]]: + """ + Verifies that the given timezone is a valid timezone. + """ + + if tz is None: + return None + if isinstance(tz, (dtm.tzinfo, zoneinfo.ZoneInfo)): + return tz + + try: + return zoneinfo.ZoneInfo(tz) + 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 diff --git a/tests/_utils/test_datetime.py b/tests/_utils/test_datetime.py index dfcaca67587..d1b9aaea91d 100644 --- a/tests/_utils/test_datetime.py +++ b/tests/_utils/test_datetime.py @@ -23,6 +23,8 @@ import pytest from telegram._utils import datetime as tg_dtm +from telegram._utils.datetime import verify_timezone +from telegram.error import TelegramError from telegram.ext import Defaults # sample time specification values categorised into absolute / delta / time-of-day @@ -168,7 +170,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): @@ -192,3 +194,53 @@ 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 + + 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.""" + assert verify_timezone(None) is None + + def test_with_invalid_timezone_string(self): + """Test with an invalid timezone string.""" + with pytest.raises(TelegramError, match="No time zone found"): + verify_timezone("Invalid/Timezone") + + def test_with_empty_string(self): + """Test with empty string input.""" + with pytest.raises(TelegramError, 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(TelegramError, match="No time zone found"): + verify_timezone(123) # integer + with pytest.raises(TelegramError, match="No time zone found"): + verify_timezone({"key": "value"}) # dict + with pytest.raises(TelegramError, match="No time zone found"): + verify_timezone([]) # empty list diff --git a/tests/test_business_classes.py b/tests/test_business_classes.py index aabf60064c6..afb8f60b765 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 @@ -589,3 +590,179 @@ 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].tzinfo == time_zone + assert result[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, 6, 0, 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