diff --git a/src/cronpal/cli.py b/src/cronpal/cli.py index 927cd25..6506f59 100644 --- a/src/cronpal/cli.py +++ b/src/cronpal/cli.py @@ -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}") @@ -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: diff --git a/src/cronpal/field_parser.py b/src/cronpal/field_parser.py index bb46aa5..ba3130d 100644 --- a/src/cronpal/field_parser.py +++ b/src/cronpal/field_parser.py @@ -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, diff --git a/src/cronpal/models.py b/src/cronpal/models.py index a340bf7..dcc03dd 100644 --- a/src/cronpal/models.py +++ b/src/cronpal/models.py @@ -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. @@ -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 diff --git a/tests/est_field_parser.py b/tests/est_field_parser.py index 2c15598..c69e412 100644 --- a/tests/est_field_parser.py +++ b/tests/est_field_parser.py @@ -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.""" @@ -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.""" @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index 22090c8..85bd970 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -101,113 +101,131 @@ def test_hour_field_parsing(): assert "Values: [0, 4, 8, 12, 16, 20]" in output -def test_both_minute_and_hour_parsing(): - """Test parsing both minute and hour fields.""" +def test_day_of_month_field_parsing(): + """Test that day of month field is parsed.""" import io import contextlib f = io.StringIO() with contextlib.redirect_stdout(f): - result = main(["*/30 9-17 * * *", "--verbose"]) + result = main(["0 0 1,15 * *", "--verbose"]) output = f.getvalue() assert result == 0 - assert "Minute field: */30" in output - assert "Values: [0, 30]" in output - assert "Hour field: 9-17" in output - assert "Values: [9, 10, 11, 12, 13, 14, 15, 16, 17]" in output + assert "Day of month field: 1,15" in output + assert "Values: [1, 15]" in output -def test_minute_field_range_parsing(): - """Test parsing minute field with range.""" +def test_all_three_fields_parsing(): + """Test parsing minute, hour, and day fields.""" import io import contextlib f = io.StringIO() with contextlib.redirect_stdout(f): - result = main(["0-5 0 * * *", "--verbose"]) + result = main(["*/30 9-17 1-7 * *", "--verbose"]) output = f.getvalue() assert result == 0 - assert "Minute field: 0-5" in output - assert "Values: [0, 1, 2, 3, 4, 5]" in output + assert "Minute field: */30" in output + assert "Values: [0, 30]" in output + assert "Hour field: 9-17" in output + assert "Values: [9, 10, 11, 12, 13, 14, 15, 16, 17]" in output + assert "Day of month field: 1-7" in output + assert "Values: [1, 2, 3, 4, 5, 6, 7]" in output -def test_hour_field_range_parsing(): - """Test parsing hour field with range.""" +def test_day_field_wildcard_parsing(): + """Test parsing day field with wildcard.""" import io import contextlib f = io.StringIO() with contextlib.redirect_stdout(f): - result = main(["0 8-18/2 * * *", "--verbose"]) + result = main(["0 0 * * *", "--verbose"]) output = f.getvalue() assert result == 0 - assert "Hour field: 8-18/2" in output - assert "Values: [8, 10, 12, 14, 16, 18]" in output + assert "Day of month field: *" in output + # Should show truncated list for 31 values + assert "Total: 31 values" in output -def test_minute_field_list_parsing(): - """Test parsing minute field with list.""" +def test_day_field_range_parsing(): + """Test parsing day field with range.""" import io import contextlib f = io.StringIO() with contextlib.redirect_stdout(f): - result = main(["0,15,30,45 0 * * *", "--verbose"]) + result = main(["0 0 10-20 * *", "--verbose"]) output = f.getvalue() assert result == 0 - assert "Minute field: 0,15,30,45" in output - assert "Values: [0, 15, 30, 45]" in output + assert "Day of month field: 10-20" in output + assert "Values: [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]" in output -def test_hour_field_list_parsing(): - """Test parsing hour field with list.""" +def test_day_field_step_parsing(): + """Test parsing day field with step.""" import io import contextlib f = io.StringIO() with contextlib.redirect_stdout(f): - result = main(["0 0,6,12,18 * * *", "--verbose"]) + result = main(["0 0 */5 * *", "--verbose"]) output = f.getvalue() assert result == 0 - assert "Hour field: 0,6,12,18" in output - assert "Values: [0, 6, 12, 18]" in output + assert "Day of month field: */5" in output + assert "Values: [1, 6, 11, 16, 21, 26, 31]" in output -def test_minute_field_wildcard_parsing(): - """Test parsing minute field with wildcard.""" +def test_day_field_list_parsing(): + """Test parsing day field with list.""" import io import contextlib f = io.StringIO() with contextlib.redirect_stdout(f): - result = main(["* 0 * * *", "--verbose"]) + result = main(["0 0 1,10,20,30 * *", "--verbose"]) output = f.getvalue() assert result == 0 - assert "Minute field: *" in output - # Should show truncated list for 60 values - assert "Total: 60 values" in output + assert "Day of month field: 1,10,20,30" in output + assert "Values: [1, 10, 20, 30]" in output -def test_hour_field_wildcard_parsing(): - """Test parsing hour field with wildcard.""" +def test_day_field_invalid_value_zero(): + """Test invalid day field value (0).""" import io import contextlib - f = io.StringIO() - with contextlib.redirect_stdout(f): - result = main(["0 * * * *", "--verbose"]) + f_err = io.StringIO() - output = f.getvalue() - assert result == 0 - assert "Hour field: *" in output - # Should show truncated list for 24 values - assert "Total: 24 values" in output + with contextlib.redirect_stderr(f_err): + result = main(["0 0 0 * *"]) + + error_output = f_err.getvalue() + assert result == 1 + assert "day of month" in error_output.lower() + assert "out of range" in error_output.lower() + + +def test_day_field_invalid_value_high(): + """Test invalid day field value (32).""" + import io + import contextlib + + f_err = io.StringIO() + + with contextlib.redirect_stderr(f_err): + result = main(["0 0 32 * *"]) + + error_output = f_err.getvalue() + assert result == 1 + assert "day of month" in error_output.lower() + assert "out of range" in error_output.lower() def test_minute_field_invalid_value(): @@ -283,7 +301,7 @@ def test_verbose_flag_valid(): f = io.StringIO() with contextlib.redirect_stdout(f): - result = main(["0 0 * * *", "--verbose"]) + result = main(["0 0 1 * *", "--verbose"]) output = f.getvalue() assert result == 0 @@ -291,6 +309,7 @@ def test_verbose_flag_valid(): assert "Validation: PASSED" in output assert "Minute field:" in output assert "Hour field:" in output + assert "Day of month field:" in output def test_verbose_flag_invalid():