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
6 changes: 6 additions & 0 deletions src/cronpal/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def main(args=None):
field_parser = FieldParser()
cron_expr.minute = field_parser.parse_minute(fields[0])
cron_expr.hour = field_parser.parse_hour(fields[1])
cron_expr.day_of_month = field_parser.parse_day_of_month(fields[2])

print(f"✓ Valid cron expression: {cron_expr}")

Expand All @@ -59,6 +60,11 @@ def main(args=None):
if cron_expr.hour.parsed_values:
_print_field_values(" ", cron_expr.hour.parsed_values)

if cron_expr.day_of_month:
print(f" Day of month field: {cron_expr.day_of_month.raw_value}")
if cron_expr.day_of_month.parsed_values:
_print_field_values(" ", cron_expr.day_of_month.parsed_values)

return 0

except CronPalError as e:
Expand Down
34 changes: 34 additions & 0 deletions src/cronpal/field_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,40 @@ def parse_hour(self, field_value: str) -> CronField:
except (ParseError, ValueError) as e:
raise FieldError("hour", str(e))

def parse_day_of_month(self, field_value: str) -> CronField:
"""
Parse the day of month field of a cron expression.

Args:
field_value: The day field string (e.g., "1", "*/2", "1-15").

Returns:
CronField object with parsed values.

Raises:
FieldError: If the field value is invalid.
"""
field_type = FieldType.DAY_OF_MONTH
field_range = FIELD_RANGES[field_type]

try:
parsed_values = self._parse_field(
field_value,
field_range,
"day of month"
)

field = CronField(
raw_value=field_value,
field_type=field_type,
field_range=field_range
)
field.parsed_values = parsed_values
return field

except (ParseError, ValueError) as e:
raise FieldError("day of month", str(e))

def _parse_field(
self,
field_value: str,
Expand Down
14 changes: 12 additions & 2 deletions src/cronpal/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,19 @@ def is_valid(self) -> bool:
self.day_of_week is not None
])

def matches_time(self, minute: int, hour: Optional[int] = None) -> bool:
def matches_time(
self,
minute: int,
hour: Optional[int] = None,
day: Optional[int] = None
) -> bool:
"""
Check if this expression matches a given time.

Args:
minute: The minute value to check (0-59).
hour: The hour value to check (0-23).
day: The day of month value to check (1-31).

Returns:
True if the time matches.
Expand All @@ -99,7 +105,11 @@ def matches_time(self, minute: int, hour: Optional[int] = None) -> bool:

if hour is not None and self.hour is not None:
hour_match = self.hour.matches(hour)
return minute_match and hour_match
minute_match = minute_match and hour_match

if day is not None and self.day_of_month is not None:
day_match = self.day_of_month.matches(day)
minute_match = minute_match and day_match

return minute_match

Expand Down
125 changes: 124 additions & 1 deletion tests/est_field_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,121 @@ def test_parse_hour_duplicates_removed(self):
assert field.parsed_values == {0, 12}


class TestParseDayOfMonth:
"""Tests for parsing day of month field."""

def setup_method(self):
"""Set up test fixtures."""
self.parser = FieldParser()

def test_parse_single_day(self):
"""Test parsing a single day value."""
field = self.parser.parse_day_of_month("1")
assert field.raw_value == "1"
assert field.field_type == FieldType.DAY_OF_MONTH
assert field.parsed_values == {1}

def test_parse_day_wildcard(self):
"""Test parsing day wildcard."""
field = self.parser.parse_day_of_month("*")
assert field.raw_value == "*"
assert field.parsed_values == set(range(1, 32))
assert len(field.parsed_values) == 31

def test_parse_day_range(self):
"""Test parsing day range."""
field = self.parser.parse_day_of_month("1-7")
assert field.parsed_values == {1, 2, 3, 4, 5, 6, 7}

def test_parse_day_list(self):
"""Test parsing day list."""
field = self.parser.parse_day_of_month("1,15,31")
assert field.parsed_values == {1, 15, 31}

def test_parse_day_step_wildcard(self):
"""Test parsing day step with wildcard."""
field = self.parser.parse_day_of_month("*/5")
assert field.parsed_values == {1, 6, 11, 16, 21, 26, 31}

def test_parse_day_step_range(self):
"""Test parsing day step with range."""
field = self.parser.parse_day_of_month("1-15/3")
assert field.parsed_values == {1, 4, 7, 10, 13}

def test_parse_day_complex(self):
"""Test parsing complex day expression."""
field = self.parser.parse_day_of_month("1-5,15,20-25/2")
expected = {1, 2, 3, 4, 5, 15, 20, 22, 24}
assert field.parsed_values == expected

def test_parse_day_max_value(self):
"""Test parsing maximum day value."""
field = self.parser.parse_day_of_month("31")
assert field.parsed_values == {31}

def test_parse_day_out_of_range_high(self):
"""Test parsing day value too high."""
with pytest.raises(FieldError, match="day of month.*out of range"):
self.parser.parse_day_of_month("32")

def test_parse_day_out_of_range_low(self):
"""Test parsing day value too low."""
with pytest.raises(FieldError, match="day of month.*out of range"):
self.parser.parse_day_of_month("0")

def test_parse_day_invalid_range(self):
"""Test parsing invalid day range."""
with pytest.raises(FieldError, match="start.*>.*end"):
self.parser.parse_day_of_month("20-10")

def test_parse_day_invalid_step(self):
"""Test parsing invalid day step."""
with pytest.raises(FieldError, match="Step value must be positive"):
self.parser.parse_day_of_month("*/0")

def test_parse_day_non_numeric(self):
"""Test parsing non-numeric day value."""
with pytest.raises(FieldError, match="not a number"):
self.parser.parse_day_of_month("first")

def test_parse_day_empty(self):
"""Test parsing empty day field."""
with pytest.raises(FieldError, match="Empty"):
self.parser.parse_day_of_month("")

def test_parse_day_first_week(self):
"""Test parsing first week of month."""
field = self.parser.parse_day_of_month("1-7")
assert field.parsed_values == {1, 2, 3, 4, 5, 6, 7}

def test_parse_day_biweekly(self):
"""Test parsing bi-weekly pattern."""
field = self.parser.parse_day_of_month("1,15")
assert field.parsed_values == {1, 15}

def test_parse_day_every_other_day(self):
"""Test parsing every other day."""
field = self.parser.parse_day_of_month("*/2")
expected = {1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 31}
assert field.parsed_values == expected

def test_parse_day_step_from_single(self):
"""Test parsing step from single day value."""
field = self.parser.parse_day_of_month("5/5")
# Should give 5, 10, 15, 20, 25, 30
assert field.parsed_values == {5, 10, 15, 20, 25, 30}

def test_parse_day_end_of_month(self):
"""Test parsing end of month days."""
field = self.parser.parse_day_of_month("28-31")
assert field.parsed_values == {28, 29, 30, 31}

def test_parse_day_duplicates_removed(self):
"""Test that duplicate day values are removed."""
field = self.parser.parse_day_of_month("1,1,15,15")
assert field.parsed_values == {1, 15}


class TestFieldParserInternals:
"""Tests for internal parsing methods."""

Expand All @@ -245,6 +360,7 @@ def setup_method(self):
from cronpal.models import FIELD_RANGES
self.minute_range = FIELD_RANGES[FieldType.MINUTE]
self.hour_range = FIELD_RANGES[FieldType.HOUR]
self.day_range = FIELD_RANGES[FieldType.DAY_OF_MONTH]

def test_parse_single_valid(self):
"""Test _parse_single with valid value."""
Expand Down Expand Up @@ -297,4 +413,11 @@ def test_hour_range_boundaries(self):
result = self.parser._parse_field("*", self.hour_range, "hour")
assert min(result) == 0
assert max(result) == 23
assert len(result) == 24
assert len(result) == 24

def test_day_range_boundaries(self):
"""Test day of month range boundaries."""
result = self.parser._parse_field("*", self.day_range, "day")
assert min(result) == 1
assert max(result) == 31
assert len(result) == 31
Loading
Loading