From 5cb85c275e7abe429213e57403eb541204b71362 Mon Sep 17 00:00:00 2001 From: Masaharu Shimizu Date: Sat, 11 Feb 2023 15:02:30 +0900 Subject: [PATCH 1/2] Add monthly and yearly job support with specified date --- schedule/__init__.py | 129 +++++++++++++++++++++++++++++++++++++++---- test_schedule.py | 68 +++++++++++++++++++++++ 2 files changed, 186 insertions(+), 11 deletions(-) diff --git a/schedule/__init__.py b/schedule/__init__.py index 8d02837d..e88c8458 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -38,13 +38,14 @@ [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 import random import re import time -from typing import Set, List, Optional, Callable, Union +from typing import Set, List, Optional, Callable, Union, Tuple logger = logging.getLogger("schedule") @@ -237,6 +238,10 @@ def __init__(self, interval: int, scheduler: Scheduler = None): # optional time zone of the self.at_time field. Only relevant when at_time is not None self.at_time_zone = None + # optional date on which this job runs + # This tuple is (month, day), and month is none if the job is a monthly job. + self.on_date: Optional[Tuple[Optional[int], int]] = None + # datetime of the last run self.last_run: Optional[datetime.datetime] = None @@ -296,7 +301,17 @@ def is_repr(j): kwargs = ["%s=%s" % (k, repr(v)) for k, v in self.job_func.keywords.items()] call_repr = job_func_name + "(" + ", ".join(args + kwargs) + ")" - if self.at_time is not None: + if self.on_date is not None: + return "Every %s %s on %s%s%s do %s %s" % ( + self.interval, + self.unit[:-1] if self.interval == 1 else self.unit, + f"{self.on_date[0]}/" if self.on_date[0] is not None else "", + self.on_date[1], + f" at {self.at_time}" if self.at_time is not None else "", + call_repr, + timestats, + ) + elif self.at_time is not None: return "Every %s %s at %s do %s %s" % ( self.interval, self.unit[:-1] if self.interval == 1 else self.unit, @@ -465,6 +480,49 @@ def tag(self, *tags: Hashable): self.tags.update(tags) return self + def date(self, date_str: str): + """ + Specify a particular date that the job should be run at. + + :param date_str: A string in one of the following formats: + - For monthly jobs -> `dd` + - For yearly jobs -> `mm/dd` + + Even if the time-unit is not explicitly specified, it determines monthly or yearly + based on the string passed as date_str. + Note that date() cannot be used in combination with the time-unit under monthly. + (e.g. `weeks`, `days`, `hours`, `minutes` and `seconds`) + + :return: The invoked job instance + """ + if ( + self.unit in ("weeks", "days", "hours", "minutes", "seconds") + or self.start_day + ): + raise ScheduleValueError( + "Invalid unit (valid units are `months` and `years`)" + ) + + if not isinstance(date_str, str): + raise TypeError("date() should be passed a string") + + if re.match(r"^(0[1-9]|1[0-2])/(0[1-9]|[12]\d|3[01])$", date_str): + self.unit = "years" + elif re.match(r"^(0[1-9]|[12]\d|3[01])$", date_str): + self.unit = "months" + else: + raise ScheduleValueError("Invalid date format (valid format is (mm/)?dd)") + + date_values = date_str.split("/") + if len(date_values) == 2: + month, day = (int(v) for v in date_values) + elif len(date_values) == 1: + month = None + day = int(date_values[0]) + + self.on_date = (month, day) + return self + def at(self, time_str: str, tz: str = None): """ @@ -488,7 +546,11 @@ def at(self, time_str: str, tz: 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 ("days", "hours", "minutes") + and not self.start_day + and not self.on_date + ): raise ScheduleValueError( "Invalid unit (valid units are `days`, `hours`, and `minutes`)" ) @@ -507,7 +569,7 @@ def at(self, time_str: str, tz: str = None): if not isinstance(time_str, str): raise TypeError("at() should be passed a string") - if self.unit == "days" or self.start_day: + if self.unit == "days" or self.start_day or self.on_date: if not re.match(r"^[0-2]\d:[0-5]\d(:[0-5]\d)?$", time_str): raise ScheduleValueError( "Invalid time format for a daily job (valid format is HH:MM(:SS)?)" @@ -539,7 +601,7 @@ def at(self, time_str: str, tz: str = None): else: hour, minute = time_values second = 0 - if self.unit == "days" or self.start_day: + if self.unit == "days" or self.start_day or self.on_date: hour = int(hour) if not (0 <= hour <= 23): raise ScheduleValueError( @@ -699,10 +761,18 @@ 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", + "years", + ): raise ScheduleValueError( "Invalid unit (valid units are `seconds`, `minutes`, `hours`, " - "`days`, and `weeks`)" + "`days`, `weeks`, `months`, and `years`)" ) if self.latest is not None: @@ -712,8 +782,22 @@ def _schedule_next_run(self) -> None: else: interval = self.interval - self.period = datetime.timedelta(**{self.unit: interval}) + if self.unit in ("months", "years"): + interval = interval * 12 if self.unit == "years" else interval + # Convert monthly interval to daily + now = datetime.datetime.now() + year_months = [(now.year, now.month + i) for i in range(interval)] + days_interval = 0 + for year, month in year_months: + while month > 12: + year += 1 + month -= 12 + days_interval += calendar.monthrange(year, month)[1] + self.period = datetime.timedelta(days=days_interval) + else: + self.period = datetime.timedelta(**{self.unit: interval}) self.next_run = datetime.datetime.now() + self.period + if self.start_day is not None: if self.unit != "weeks": raise ScheduleValueError("`unit` should be 'weeks'") @@ -735,13 +819,32 @@ def _schedule_next_run(self) -> None: if days_ahead <= 0: # Target day already happened this week days_ahead += 7 self.next_run += datetime.timedelta(days_ahead) - self.period + if self.on_date is not None: + if self.unit not in ("months", "years"): + raise ScheduleValueError("`unit` should be 'months' or 'years'") + kwargs = {"day": self.on_date[1]} + if self.on_date[0] is not None: + kwargs["month"] = self.on_date[0] + self.next_run = self.next_run.replace(**kwargs) # type: ignore[arg-type] 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 ("days", "hours", "minutes") + and self.start_day is None + and self.on_date is None + ): raise ScheduleValueError("Invalid unit without specifying start day") kwargs = {"second": self.at_time.second, "microsecond": 0} - if self.unit == "days" or self.start_day is not None: + if ( + self.unit == "days" + or self.start_day is not None + or self.on_date 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 ["days", "hours"] + or self.start_day is not None + or self.on_date is not None + ): kwargs["minute"] = self.at_time.minute self.next_run = self.next_run.replace(**kwargs) # type: ignore @@ -779,6 +882,10 @@ def _schedule_next_run(self) -> None: # Let's see if we will still make that time we specified today if (self.next_run - datetime.datetime.now()).days >= 7: self.next_run -= self.period + if self.on_date is not None: + # Make sure that next_run is within the period specified by interval + if (self.next_run - datetime.datetime.now()).days >= days_interval: + self.next_run -= datetime.timedelta(days=days_interval) def _is_overdue(self, when: datetime.datetime): return self.cancel_after is not None and when > self.cancel_after diff --git a/test_schedule.py b/test_schedule.py index 7b4b83da..5d3fe647 100644 --- a/test_schedule.py +++ b/test_schedule.py @@ -384,6 +384,74 @@ def test_weekday_at_todady(self): assert job.next_run.month == 11 assert job.next_run.day == 25 + def test_on_date(self): + with mock_datetime(2023, 10, 10, 10, 10): + mock_job = make_mock_job() + # Unit is `months` + assert every().date("09").do(mock_job).next_run.year == 2023 + assert every().date("09").do(mock_job).next_run.month == 11 + assert every().date("09").do(mock_job).next_run.day == 9 + assert every().date("11").do(mock_job).next_run.year == 2023 + assert every().date("11").do(mock_job).next_run.month == 10 + assert every().date("11").do(mock_job).next_run.day == 11 + assert every(3).date("09").do(mock_job).next_run.year == 2024 + assert every(3).date("09").do(mock_job).next_run.month == 1 + assert every(3).date("09").do(mock_job).next_run.day == 9 + assert every(3).date("11").do(mock_job).next_run.year == 2023 + assert every(3).date("11").do(mock_job).next_run.month == 10 + assert every(3).date("11").do(mock_job).next_run.day == 11 + + # Unit is `years` + assert every().date("10/09").do(mock_job).next_run.year == 2024 + assert every().date("10/09").do(mock_job).next_run.month == 10 + assert every().date("10/09").do(mock_job).next_run.day == 9 + assert every().date("10/11").do(mock_job).next_run.year == 2023 + assert every().date("10/11").do(mock_job).next_run.month == 10 + assert every().date("10/11").do(mock_job).next_run.day == 11 + assert every(3).date("10/09").do(mock_job).next_run.year == 2026 + assert every(3).date("10/09").do(mock_job).next_run.month == 10 + assert every(3).date("10/09").do(mock_job).next_run.day == 9 + assert every(3).date("10/11").do(mock_job).next_run.year == 2023 + assert every(3).date("10/11").do(mock_job).next_run.month == 10 + assert every(3).date("10/11").do(mock_job).next_run.day == 11 + + with self.assertRaises(ScheduleValueError): + every().date("09").seconds.do(mock_job) + every().date("09").minutes.do(mock_job) + every().date("09").hours.do(mock_job) + every().date("09").days.do(mock_job) + every().date("09").weeks.do(mock_job) + + self.assertRaises(ScheduleValueError, every().second.date, "09") + self.assertRaises(ScheduleValueError, every().minute.date, "09") + self.assertRaises(ScheduleValueError, every().hour.date, "09") + self.assertRaises(ScheduleValueError, every().day.date, "09") + self.assertRaises(ScheduleValueError, every().week.date, "09") + self.assertRaises(ScheduleValueError, every().date, "1/09") + self.assertRaises(ScheduleValueError, every().date, "01/9") + self.assertRaises(ScheduleValueError, every().date, "00/09") + self.assertRaises(ScheduleValueError, every().date, "13/09") + self.assertRaises(ScheduleValueError, every().date, "01/00") + self.assertRaises(ScheduleValueError, every().date, "01/32") + self.assertRaises(ScheduleValueError, every().date, "01-09") + self.assertRaises(ScheduleValueError, every().date, "9") + self.assertRaises(ScheduleValueError, every().date, "00") + self.assertRaises(ScheduleValueError, every().date, "32") + self.assertRaises(TypeError, every().date, 9) + + def test_on_date_repr(self): + mock_job = make_mock_job() + + with mock_datetime(2023, 10, 10, 10, 10): + job_repr = repr(every(3).date("11").do(mock_job)) + assert job_repr.startswith("Every 3 months on 11 do job()") + job_repr = repr(every(3).date("01/11").do(mock_job)) + assert job_repr.startswith("Every 3 years on 1/11 do job()") + job_repr = repr(every(3).date("11").at("10:00:00").do(mock_job)) + assert job_repr.startswith("Every 3 months on 11 at 10:00:00 do job()") + job_repr = repr(every(3).date("01/11").at("10:00:00").do(mock_job)) + assert job_repr.startswith("Every 3 years on 1/11 at 10:00:00 do job()") + def test_at_time_hour(self): with mock_datetime(2010, 1, 6, 12, 20): mock_job = make_mock_job() From d2bda902474717165ef2f2d35702038afad88abb Mon Sep 17 00:00:00 2001 From: Masaharu Shimizu Date: Sat, 11 Feb 2023 15:38:50 +0900 Subject: [PATCH 2/2] Update documents --- README.rst | 2 ++ docs/examples.rst | 12 ++++++++++++ docs/index.rst | 2 ++ 3 files changed, 16 insertions(+) diff --git a/README.rst b/README.rst index 176e961b..15f471ed 100644 --- a/README.rst +++ b/README.rst @@ -43,6 +43,8 @@ Usage 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().date("05").do(job) + schedule.every().date("12/05").at("09:00").do(job) def job_with_argument(name): print(f"I am {name}") diff --git a/docs/examples.rst b/docs/examples.rst index d214658f..508a541a 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -43,6 +43,18 @@ Run a job every x minute schedule.every().wednesday.at("13:15").do(job) schedule.every().minute.at(":17").do(job) + # Run jobs every month on the 15th + schedule.every().date("15").do(job) + + # Run jobs every 3 months at 12:34 on the 20th + schedule.every(3).date("20").at("12:34").do(job) + + # Run jobs every year on the July 15th + schedule.every().date("07/15").do(job) + + # Run jobs every 3 years at 11:11 on the August 25th + schedule.every(3).date("08/25").at("11:11").do(job) + while True: schedule.run_pending() time.sleep(1) diff --git a/docs/index.rst b/docs/index.rst index 7e983d38..8b810fcb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -42,6 +42,8 @@ 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().date("05").do(job) + schedule.every().date("12/05").at("09:00").do(job) while True: schedule.run_pending()