diff --git a/AUTHORS.rst b/AUTHORS.rst index 63189ee0..bee45014 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -44,4 +44,5 @@ Thanks to all the wonderful folks who have contributed to schedule over the year - sunpro108 - kurtasov - AnezeR +- Workbench3D diff --git a/HISTORY.rst b/HISTORY.rst index fa6866db..76e6bf5b 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,11 @@ History ------- +1.3.0 (2024-03-17) +++++++++++++++++++ + +- Add supports "months" interval (#487) + 1.2.1 (2023-11-01) ++++++++++++++++++ diff --git a/docs/conf.py b/docs/conf.py index b5d1e6cd..d791716d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = u"1.2.1" +version = u"1.3.0" # The full version, including alpha/beta/rc tags. -release = u"1.2.1" +release = u"1.3.0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/examples.rst b/docs/examples.rst index ac8f32ac..2788c404 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -42,6 +42,16 @@ Run a job every x minute schedule.every().monday.do(job) schedule.every().wednesday.at("13:15").do(job) + # Run job every 3 months and every month + schedule.every(3).months.do(job) + schedule.every().month.do(job) + + # Run job every month at specific "DD HH:MM" and next "DD HH:MM:SS" + # Supported days are 1-28 + # Not supported are 29, 30, 31, due to the days in February. + schedule.every(3).months.at("28 12:30").do(job) + schedule.every().month.at("01 12:44:02", "UTC").do(job) + while True: schedule.run_pending() time.sleep(1) diff --git a/docs/index.rst b/docs/index.rst index 966608aa..d8fa12b7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -42,6 +42,7 @@ Python job scheduling for humans. Run Python functions (or any other callable) p schedule.every().wednesday.at("13:15").do(job) schedule.every().day.at("12:42", "Europe/Amsterdam").do(job) schedule.every().minute.at(":17").do(job) + schedule.every().month.at("28 12:30").do(job) while True: schedule.run_pending() diff --git a/schedule/__init__.py b/schedule/__init__.py index 3f7267da..e21f5357 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -38,6 +38,7 @@ [3] https://adam.herokuapp.com/past/2010/6/30/replace_cron_with_clockwork/ """ from collections.abc import Hashable +import calendar import datetime import functools import logging @@ -378,6 +379,17 @@ def weeks(self): self.unit = "weeks" return self + @property + def month(self): + if self.interval != 1: + raise IntervalError("Use months instead of month") + return self.months + + @property + def months(self): + self.unit = "months" + return self + @property def monday(self): if self.interval != 1: @@ -491,9 +503,12 @@ def at(self, time_str: str, tz: Optional[str] = None): :return: The invoked job instance """ - if self.unit not in ("days", "hours", "minutes") and not self.start_day: + if ( + self.unit not in ("months", "days", "hours", "minutes") + and not self.start_day + ): raise ScheduleValueError( - "Invalid unit (valid units are `days`, `hours`, and `minutes`)" + "Invalid unit (valid units are `months`, `days`, `hours` and `minutes`)" ) if tz is not None: @@ -510,6 +525,14 @@ def at(self, time_str: str, tz: Optional[str] = None): if not isinstance(time_str, str): raise TypeError("at() should be passed a string") + if self.unit == "months": + if not re.match( + r"^(?:[01][1-9]|2[0-8]) (?:[01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$", time_str + ): + raise ScheduleValueError( + "Invalid time format for a monthly job (valid format is 'DD HH:MM(:SS)'?) " + "and day is between 0 and 28" + ) if self.unit == "days" or self.start_day: if not re.match(r"^[0-2]\d:[0-5]\d(:[0-5]\d)?$", time_str): raise ScheduleValueError( @@ -526,11 +549,17 @@ def at(self, time_str: str, tz: Optional[str] = None): raise ScheduleValueError( "Invalid time format for a minutely job (valid format is :SS)" ) - time_values = time_str.split(":") + time_values = re.split("[ :]", time_str) + day = Union[str, int] hour: Union[str, int] minute: Union[str, int] second: Union[str, int] - if len(time_values) == 3: + if len(time_values) == 4: + day, hour, minute, second = time_values + elif len(time_values) == 3 and self.unit == "months": + second = 0 + day, hour, minute = time_values + elif len(time_values) == 3: hour, minute, second = time_values elif len(time_values) == 2 and self.unit == "minutes": hour = 0 @@ -556,7 +585,14 @@ def at(self, time_str: str, tz: Optional[str] = None): hour = int(hour) minute = int(minute) second = int(second) - self.at_time = datetime.time(hour, minute, second) + if self.unit == "months": + now = datetime.datetime.now() + day = int(day) + self.at_time = datetime.datetime( + now.year, now.month, day, hour, minute, second + ) + else: + self.at_time = datetime.time(hour, minute, second) return self def to(self, latest: int): @@ -702,10 +738,10 @@ def _schedule_next_run(self) -> None: """ Compute the instant when this job should run next. """ - if self.unit not in ("seconds", "minutes", "hours", "days", "weeks"): + if self.unit not in ("seconds", "minutes", "hours", "days", "weeks", "months"): raise ScheduleValueError( "Invalid unit (valid units are `seconds`, `minutes`, `hours`, " - "`days`, and `weeks`)" + "`days`, `weeks` and `months`)" ) if self.latest is not None: @@ -721,8 +757,26 @@ def _schedule_next_run(self) -> None: else: now = datetime.datetime.now() - self.period = datetime.timedelta(**{self.unit: interval}) - self.next_run = now + self.period + if self.unit == "months": + month = now.month - 1 + interval + year = now.year + month // 12 + month = month % 12 + 1 + if self.at_time is not None: + day = self.at_time.day + else: + day = min(now.day, calendar.monthrange(year, month)[1]) + self.next_run = datetime.datetime( + year, + month, + day, + now.hour, + now.minute, + now.second, + tzinfo=self.at_time_zone, + ) + else: + self.period = datetime.timedelta(**{self.unit: interval}) + self.next_run = now + self.period if self.start_day is not None: if self.unit != "weeks": raise ScheduleValueError("`unit` should be 'weeks'") @@ -751,12 +805,17 @@ def _schedule_next_run(self) -> None: self.next_run = self.at_time_zone.normalize(self.next_run) if self.at_time is not None: - if self.unit not in ("days", "hours", "minutes") and self.start_day is None: + if ( + self.unit not in ("months", "days", "hours", "minutes") + and self.start_day is None + ): raise ScheduleValueError("Invalid unit without specifying start day") kwargs = {"second": self.at_time.second, "microsecond": 0} + if self.unit == "months" or self.start_day is not None: + kwargs["hour"] = self.at_time.hour if self.unit == "days" or self.start_day is not None: kwargs["hour"] = self.at_time.hour - if self.unit in ["days", "hours"] or self.start_day is not None: + if self.unit in ["months", "days", "hours"] or self.start_day is not None: kwargs["minute"] = self.at_time.minute self.next_run = self.next_run.replace(**kwargs) # type: ignore diff --git a/setup.py b/setup.py index 9bd5a4f9..9f9e8c22 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup -SCHEDULE_VERSION = "1.2.1" +SCHEDULE_VERSION = "1.3.0" SCHEDULE_DOWNLOAD_URL = "https://github.com/dbader/schedule/tarball/" + SCHEDULE_VERSION diff --git a/test_schedule.py b/test_schedule.py index 875d5ebc..8bcc352d 100644 --- a/test_schedule.py +++ b/test_schedule.py @@ -1160,3 +1160,73 @@ def test_misconfigured_job_wont_break_scheduler(self): scheduler.every() scheduler.every(10).seconds scheduler.run_pending() + + def test_time_month(self): + assert every().months.unit == "months" + + # Test last day month + with mock_datetime(2010, 1, 31, 11, 20): + mock_job = make_mock_job() + + assert every().month.do(mock_job).next_run.month == 2 + assert every().month.do(mock_job).next_run.day == 28 + + assert every(3).months.do(mock_job).next_run.month == 4 + assert every(3).months.do(mock_job).next_run.day == 30 + + with mock_datetime(2010, 1, 16, 11, 20): + mock_job = make_mock_job() + + assert every().month.do(mock_job).next_run.year == 2010 + assert every().month.do(mock_job).next_run.month == 2 + assert every().month.do(mock_job).next_run.day == 16 + assert every().month.do(mock_job).next_run.hour == 11 + assert every().month.do(mock_job).next_run.minute == 20 + + assert every(3).months.do(mock_job).next_run.year == 2010 + assert every(3).months.do(mock_job).next_run.month == 4 + assert every(3).months.do(mock_job).next_run.day == 16 + assert every(3).months.do(mock_job).next_run.hour == 11 + assert every(3).months.do(mock_job).next_run.minute == 20 + + assert every().month.at("28 12:30").do(mock_job).next_run.year == 2010 + assert every().month.at("28 12:30").do(mock_job).next_run.month == 2 + assert every().month.at("28 12:30").do(mock_job).next_run.day == 28 + assert every().month.at("28 12:30").do(mock_job).next_run.hour == 12 + assert every().month.at("28 12:30").do(mock_job).next_run.minute == 30 + assert every().month.at("28 12:30:15").do(mock_job).next_run.second == 15 + + assert every().month.at("19 12:10").do(mock_job).next_run.year == 2010 + assert every().month.at("19 12:10").do(mock_job).next_run.month == 2 + assert every().month.at("19 12:10").do(mock_job).next_run.day == 19 + assert every().month.at("19 12:10").do(mock_job).next_run.hour == 12 + assert every().month.at("19 12:10").do(mock_job).next_run.minute == 10 + assert every().month.at("19 12:10:59").do(mock_job).next_run.second == 59 + + assert every(3).months.at("28 12:30").do(mock_job).next_run.year == 2010 + assert every(3).months.at("28 12:30").do(mock_job).next_run.month == 4 + assert every(3).months.at("28 12:30").do(mock_job).next_run.day == 28 + assert every(3).months.at("28 12:30").do(mock_job).next_run.hour == 12 + assert every(3).months.at("28 12:30").do(mock_job).next_run.minute == 30 + assert every(3).months.at("28 12:30:15").do(mock_job).next_run.second == 15 + + assert every(3).months.at("19 12:10").do(mock_job).next_run.year == 2010 + assert every(3).months.at("19 12:10").do(mock_job).next_run.month == 4 + assert every(3).months.at("19 12:10").do(mock_job).next_run.day == 19 + assert every(3).months.at("19 12:10").do(mock_job).next_run.hour == 12 + assert every(3).months.at("19 12:10").do(mock_job).next_run.minute == 10 + assert every(3).months.at("19 12:10:59").do(mock_job).next_run.second == 59 + + self.assertRaises(ScheduleValueError, every().month.at, "31 02:30:00") + self.assertRaises(ScheduleValueError, every().month.at, "30 02:30:00") + self.assertRaises(ScheduleValueError, every().month.at, "29 02:30:00") + self.assertRaises(ScheduleValueError, every().month.at, "9 02:30:00") + self.assertRaises(ScheduleValueError, every().month.at, "2:30:00") + self.assertRaises(ScheduleValueError, every().month.at, "::2") + self.assertRaises(ScheduleValueError, every().month.at, ".2") + self.assertRaises(ScheduleValueError, every().month.at, "2") + self.assertRaises(ScheduleValueError, every().month.at, " 2:30") + self.assertRaises(ScheduleValueError, every().month.at, "61:00") + self.assertRaises(ScheduleValueError, every().month.at, "00:61") + self.assertRaises(ScheduleValueError, every().month.at, "01:61") + self.assertRaises(TypeError, every().month.at, 2)