Skip to content
Merged
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
106 changes: 95 additions & 11 deletions schedule/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
>>> schedule.every(5).to(10).days.do(job)
>>> schedule.every().hour.do(job, message='things')
>>> schedule.every().day.at("10:30").do(job)
>>> schedule.every().month.do(job) # Run monthly
>>> schedule.every(3).months.do(job) # Run every 3 months
>>> schedule.every().year.do(job) # Run yearly

>>> while True:
>>> schedule.run_pending()
Expand All @@ -39,6 +42,7 @@
"""

from collections.abc import Hashable
import calendar
import datetime
import functools
import logging
Expand Down Expand Up @@ -206,6 +210,37 @@ def idle_seconds(self) -> Optional[float]:
return (self.next_run - datetime.datetime.now()).total_seconds()


def _add_months_years(
dt: datetime.datetime, months: int = 0, years: int = 0
) -> datetime.datetime:
"""
Add months and/or years to a datetime, handling edge cases like leap years
and month-end dates properly.

:param dt: The datetime to add to
:param months: Number of months to add
:param years: Number of years to add
:return: New datetime with months/years added
"""
# Calculate the target year and month
target_year = dt.year + years
target_month = dt.month + months

# Handle month overflow/underflow
while target_month > 12:
target_month -= 12
target_year += 1
while target_month < 1:
target_month += 12
target_year -= 1

# Handle day overflow (e.g., Jan 31 + 1 month should be Feb 28/29)
max_day = calendar.monthrange(target_year, target_month)[1]
target_day = min(dt.day, max_day)

return dt.replace(year=target_year, month=target_month, day=target_day)


class Job:
"""
A periodic job as used by :class:`Scheduler`.
Expand Down Expand Up @@ -377,6 +412,28 @@ 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 year(self):
if self.interval != 1:
raise IntervalError("Use years instead of year")
return self.years

@property
def years(self):
self.unit = "years"
return self

@property
def monday(self):
if self.interval != 1:
Expand Down Expand Up @@ -490,9 +547,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 ("days", "hours", "minutes", "months", "years")
and not self.start_day
):
raise ScheduleValueError(
"Invalid unit (valid units are `days`, `hours`, and `minutes`)"
"Invalid unit (valid units are `days`, `hours`, `minutes`, `months`, and `years`)"
)

if tz is not None:
Expand Down Expand Up @@ -701,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:
if not (self.latest >= self.interval):
Expand All @@ -726,12 +794,25 @@ def _schedule_next_run(self) -> None:
if self.at_time is not None:
next_run = self._move_to_at_time(next_run)

period = datetime.timedelta(**{self.unit: interval})
if interval != 1:
next_run += period
# Handle months and years differently since they can't use timedelta
if self.unit in ("months", "years"):
if self.unit == "months":
if interval != 1:
next_run = _add_months_years(next_run, months=interval)
while next_run <= now:
next_run = _add_months_years(next_run, months=interval)
else: # years
if interval != 1:
next_run = _add_months_years(next_run, years=interval)
while next_run <= now:
next_run = _add_months_years(next_run, years=interval)
else:
period = datetime.timedelta(**{self.unit: interval})
if interval != 1:
next_run += period

while next_run <= now:
next_run += period
while next_run <= now:
next_run += period

next_run = self._correct_utc_offset(
next_run, fixate_time=(self.at_time is not None)
Expand All @@ -756,10 +837,13 @@ def _move_to_at_time(self, moment: datetime.datetime) -> datetime.datetime:

kwargs = {"second": self.at_time.second, "microsecond": 0}

if self.unit == "days" or self.start_day is not None:
if self.unit in ["days", "months", "years"] 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 ["days", "hours", "months", "years"]
or self.start_day is not None
):
kwargs["minute"] = self.at_time.minute

moment = moment.replace(**kwargs) # type: ignore
Expand Down
134 changes: 134 additions & 0 deletions test_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -1597,3 +1597,137 @@ def test_misconfigured_job_wont_break_scheduler(self):
scheduler.every()
scheduler.every(10).seconds
scheduler.run_pending()

def test_monthly_scheduling(self):
"""Test monthly scheduling functionality."""
with mock_datetime(2023, 1, 15, 12, 0, 0):
mock_job = make_mock_job()

# Test every().month
job = every().month.do(mock_job)
assert job.unit == "months"
assert job.interval == 1

# Test every(3).months
job = every(3).months.do(mock_job)
assert job.unit == "months"
assert job.interval == 3

# Test that next_run is calculated correctly
job = every().month.do(mock_job)
expected_next_run = datetime.datetime(2023, 2, 15, 12, 0, 0)
assert job.next_run == expected_next_run

def test_yearly_scheduling(self):
"""Test yearly scheduling functionality."""
with mock_datetime(2023, 6, 15, 12, 0, 0):
mock_job = make_mock_job()

# Test every().year
job = every().year.do(mock_job)
assert job.unit == "years"
assert job.interval == 1

# Test every(2).years
job = every(2).years.do(mock_job)
assert job.unit == "years"
assert job.interval == 2

# Test that next_run is calculated correctly
job = every().year.do(mock_job)
expected_next_run = datetime.datetime(2024, 6, 15, 12, 0, 0)
assert job.next_run == expected_next_run

def test_monthly_with_at_time(self):
"""Test monthly scheduling with specific time."""
with mock_datetime(2023, 1, 15, 12, 0, 0):
mock_job = make_mock_job()

# Test every().month.at("10:30")
job = every().month.at("10:30").do(mock_job)
expected_next_run = datetime.datetime(2023, 2, 15, 10, 30, 0)
assert job.next_run == expected_next_run

def test_yearly_with_at_time(self):
"""Test yearly scheduling with specific time."""
with mock_datetime(2023, 6, 15, 12, 0, 0):
mock_job = make_mock_job()

# Test every().year.at("09:00")
job = every().year.at("09:00").do(mock_job)
expected_next_run = datetime.datetime(2024, 6, 15, 9, 0, 0)
assert job.next_run == expected_next_run

def test_month_end_edge_cases(self):
"""Test edge cases with month-end dates."""
# Test Jan 31 -> Feb 28 (non-leap year)
with mock_datetime(2023, 1, 31, 12, 0, 0):
mock_job = make_mock_job()
job = every().month.do(mock_job)
expected_next_run = datetime.datetime(2023, 2, 28, 12, 0, 0)
assert job.next_run == expected_next_run

# Test Jan 31 -> Feb 29 (leap year)
with mock_datetime(2024, 1, 31, 12, 0, 0):
mock_job = make_mock_job()
job = every().month.do(mock_job)
expected_next_run = datetime.datetime(2024, 2, 29, 12, 0, 0)
assert job.next_run == expected_next_run

# Test May 31 -> June 30
with mock_datetime(2023, 5, 31, 12, 0, 0):
mock_job = make_mock_job()
job = every().month.do(mock_job)
expected_next_run = datetime.datetime(2023, 6, 30, 12, 0, 0)
assert job.next_run == expected_next_run

def test_leap_year_edge_cases(self):
"""Test leap year edge cases for yearly scheduling."""
# Test Feb 29 -> Feb 28 (leap year to non-leap year)
with mock_datetime(2024, 2, 29, 12, 0, 0):
mock_job = make_mock_job()
job = every().year.do(mock_job)
expected_next_run = datetime.datetime(2025, 2, 28, 12, 0, 0)
assert job.next_run == expected_next_run

def test_multiple_months_years(self):
"""Test scheduling with multiple months/years intervals."""
# Test every 3 months
with mock_datetime(2023, 1, 15, 12, 0, 0):
mock_job = make_mock_job()
job = every(3).months.do(mock_job)
expected_next_run = datetime.datetime(2023, 4, 15, 12, 0, 0)
assert job.next_run == expected_next_run

# Test every 2 years
with mock_datetime(2023, 6, 15, 12, 0, 0):
mock_job = make_mock_job()
job = every(2).years.do(mock_job)
expected_next_run = datetime.datetime(2025, 6, 15, 12, 0, 0)
assert job.next_run == expected_next_run

def test_singular_month_year_validation(self):
"""Test that singular forms only work with interval=1."""
with self.assertRaises(IntervalError):
every(2).month.do(make_mock_job())

with self.assertRaises(IntervalError):
every(3).year.do(make_mock_job())

def test_monthly_yearly_job_execution(self):
"""Test that monthly and yearly jobs execute correctly."""
mock_job = make_mock_job()

# Test monthly job execution
with mock_datetime(2023, 1, 15, 12, 0, 0):
job = every().month.do(mock_job)

# Simulate time passing to next month
with mock_datetime(2023, 2, 15, 12, 0, 0):
assert job.should_run
job.run()
assert mock_job.call_count == 1

# Next run should be in March
expected_next_run = datetime.datetime(2023, 3, 15, 12, 0, 0)
assert job.next_run == expected_next_run
Loading