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 specific day on month #615

Open
wants to merge 1 commit 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,5 @@ Thanks to all the wonderful folks who have contributed to schedule over the year
- sunpro108 <https://github.com/sunpro108>
- kurtasov <https://github.com/kurtasov>
- AnezeR <https://github.com/AnezeR>
- Workbench3D <https://github.com/Workbench3D>

5 changes: 5 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
History
-------

1.3.0 (2024-03-17)
++++++++++++++++++

- Add supports "months" interval (#487)

1.2.1 (2023-11-01)
++++++++++++++++++

Expand Down
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
81 changes: 70 additions & 11 deletions schedule/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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'")
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


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