Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the functionality to run jobs at monthly or yearly intervals on specific dates #565

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
12 changes: 12 additions & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
129 changes: 118 additions & 11 deletions schedule/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):

"""
Expand All @@ -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`)"
)
Expand All @@ -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)?)"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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'")
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions test_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down