diff --git a/src/cronpal/cli.py b/src/cronpal/cli.py index 6506f59..ffd64f6 100644 --- a/src/cronpal/cli.py +++ b/src/cronpal/cli.py @@ -43,6 +43,7 @@ def main(args=None): 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]) + cron_expr.month = field_parser.parse_month(fields[3]) print(f"✓ Valid cron expression: {cron_expr}") @@ -65,6 +66,12 @@ def main(args=None): if cron_expr.day_of_month.parsed_values: _print_field_values(" ", cron_expr.day_of_month.parsed_values) + if cron_expr.month: + print(f" Month field: {cron_expr.month.raw_value}") + if cron_expr.month.parsed_values: + _print_field_values(" ", cron_expr.month.parsed_values) + _print_month_names(" ", cron_expr.month.parsed_values) + return 0 except CronPalError as e: @@ -104,5 +111,23 @@ def _print_field_values(prefix: str, values: set): print(f"{prefix}Total: {len(sorted_values)} values") +def _print_month_names(prefix: str, values: set): + """ + Print month names for month values. + + Args: + prefix: Prefix for each line. + values: Set of month numbers to convert to names. + """ + month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + + sorted_values = sorted(values) + names = [month_names[v - 1] for v in sorted_values if 1 <= v <= 12] + + if len(names) <= 10: + print(f"{prefix}Months: {', '.join(names)}") + + if __name__ == "__main__": sys.exit(main()) \ No newline at end of file diff --git a/src/cronpal/field_parser.py b/src/cronpal/field_parser.py index ba3130d..7eb4dda 100644 --- a/src/cronpal/field_parser.py +++ b/src/cronpal/field_parser.py @@ -2,7 +2,7 @@ from typing import List, Set -from cronpal.constants import WILDCARD +from cronpal.constants import MONTH_NAMES, WILDCARD from cronpal.exceptions import FieldError, ParseError from cronpal.models import CronField, FieldRange, FieldType, FIELD_RANGES @@ -112,6 +112,61 @@ def parse_day_of_month(self, field_value: str) -> CronField: except (ParseError, ValueError) as e: raise FieldError("day of month", str(e)) + def parse_month(self, field_value: str) -> CronField: + """ + Parse the month field of a cron expression. + + Args: + field_value: The month field string (e.g., "1", "JAN", "JAN-MAR"). + + Returns: + CronField object with parsed values. + + Raises: + FieldError: If the field value is invalid. + """ + field_type = FieldType.MONTH + field_range = FIELD_RANGES[field_type] + + try: + # Replace month names with numbers + normalized = self._normalize_month_names(field_value) + + parsed_values = self._parse_field( + normalized, + field_range, + "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("month", str(e)) + + def _normalize_month_names(self, field_value: str) -> str: + """ + Replace month names with their numeric values. + + Args: + field_value: The field value possibly containing month names. + + Returns: + Field value with month names replaced by numbers. + """ + normalized = field_value.upper() + + # Replace month names with numbers + for name, number in MONTH_NAMES.items(): + normalized = normalized.replace(name, str(number)) + + return normalized + def _parse_field( self, field_value: str, diff --git a/src/cronpal/models.py b/src/cronpal/models.py index dcc03dd..a1a7673 100644 --- a/src/cronpal/models.py +++ b/src/cronpal/models.py @@ -85,7 +85,8 @@ def matches_time( self, minute: int, hour: Optional[int] = None, - day: Optional[int] = None + day: Optional[int] = None, + month: Optional[int] = None ) -> bool: """ Check if this expression matches a given time. @@ -94,6 +95,7 @@ def matches_time( 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). + month: The month value to check (1-12). Returns: True if the time matches. @@ -101,17 +103,18 @@ def matches_time( if self.minute is None: return False - minute_match = self.minute.matches(minute) + matches = self.minute.matches(minute) if hour is not None and self.hour is not None: - hour_match = self.hour.matches(hour) - minute_match = minute_match and hour_match + matches = matches and self.hour.matches(hour) 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 + matches = matches and self.day_of_month.matches(day) - return minute_match + if month is not None and self.month is not None: + matches = matches and self.month.matches(month) + + return matches # Define valid ranges for each field type diff --git a/tests/test_cli.py b/tests/test_cli.py index 85bd970..19029f5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -345,4 +345,214 @@ def test_special_string(): output = f.getvalue() assert result == 0 assert "✓ Valid" in output - assert "@daily" in output \ No newline at end of file + assert "@daily" in output + + +def test_month_field_parsing(): + """Test that month field is parsed.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 1 6 *", "--verbose"]) + + output = f.getvalue() + assert result == 0 + assert "Month field: 6" in output + assert "Values: [6]" in output + assert "Months: Jun" in output + + +def test_month_field_name_parsing(): + """Test that month field with names is parsed.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 1 JAN *", "--verbose"]) + + output = f.getvalue() + assert result == 0 + assert "Month field: JAN" in output + assert "Values: [1]" in output + assert "Months: Jan" in output + + +def test_month_field_range_parsing(): + """Test parsing month field with range.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 1 1-3 *", "--verbose"]) + + output = f.getvalue() + assert result == 0 + assert "Month field: 1-3" in output + assert "Values: [1, 2, 3]" in output + assert "Months: Jan, Feb, Mar" in output + + +def test_month_field_name_range_parsing(): + """Test parsing month field with name range.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 1 JAN-MAR *", "--verbose"]) + + output = f.getvalue() + assert result == 0 + assert "Month field: JAN-MAR" in output + assert "Values: [1, 2, 3]" in output + assert "Months: Jan, Feb, Mar" in output + + +def test_month_field_list_parsing(): + """Test parsing month field with list.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 1 1,6,12 *", "--verbose"]) + + output = f.getvalue() + assert result == 0 + assert "Month field: 1,6,12" in output + assert "Values: [1, 6, 12]" in output + assert "Months: Jan, Jun, Dec" in output + + +def test_month_field_name_list_parsing(): + """Test parsing month field with name list.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 1 JAN,JUN,DEC *", "--verbose"]) + + output = f.getvalue() + assert result == 0 + assert "Month field: JAN,JUN,DEC" in output + assert "Values: [1, 6, 12]" in output + assert "Months: Jan, Jun, Dec" in output + + +def test_month_field_step_parsing(): + """Test parsing month field with step.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 1 */3 *", "--verbose"]) + + output = f.getvalue() + assert result == 0 + assert "Month field: */3" in output + assert "Values: [1, 4, 7, 10]" in output + assert "Months: Jan, Apr, Jul, Oct" in output + + +def test_month_field_wildcard_parsing(): + """Test parsing month field with wildcard.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 1 * *", "--verbose"]) + + output = f.getvalue() + assert result == 0 + assert "Month field: *" in output + # Should show all 12 months + assert "Total: 12 values" in output + + +def test_month_field_invalid_value_zero(): + """Test invalid month field value (0).""" + import io + import contextlib + + f_err = io.StringIO() + + with contextlib.redirect_stderr(f_err): + result = main(["0 0 1 0 *"]) + + error_output = f_err.getvalue() + assert result == 1 + assert "month" in error_output.lower() + assert "out of range" in error_output.lower() + + +def test_month_field_invalid_value_high(): + """Test invalid month field value (13).""" + import io + import contextlib + + f_err = io.StringIO() + + with contextlib.redirect_stderr(f_err): + result = main(["0 0 1 13 *"]) + + error_output = f_err.getvalue() + assert result == 1 + assert "month" in error_output.lower() + assert "out of range" in error_output.lower() + + +def test_month_field_invalid_name(): + """Test invalid month name.""" + import io + import contextlib + + f_err = io.StringIO() + + with contextlib.redirect_stderr(f_err): + result = main(["0 0 1 JANUARY *"]) + + error_output = f_err.getvalue() + assert result == 1 + assert "month" in error_output.lower() + assert "not a number" in error_output.lower() + + +def test_month_field_mixed_parsing(): + """Test parsing month field with mixed names and numbers.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 1 1,FEB,3,APR *", "--verbose"]) + + output = f.getvalue() + assert result == 0 + assert "Month field: 1,FEB,3,APR" in output + assert "Values: [1, 2, 3, 4]" in output + assert "Months: Jan, Feb, Mar, Apr" in output + + +def test_all_four_fields_parsing(): + """Test parsing minute, hour, day, and month fields.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["30 2 15 JAN-MAR *", "--verbose"]) + + output = f.getvalue() + assert result == 0 + assert "Minute field: 30" in output + assert "Hour field: 2" in output + assert "Day of month field: 15" in output + assert "Month field: JAN-MAR" in output + assert "Months: Jan, Feb, Mar" in output \ No newline at end of file diff --git a/tests/est_field_parser.py b/tests/test_field_parser.py similarity index 74% rename from tests/est_field_parser.py rename to tests/test_field_parser.py index c69e412..03376f0 100644 --- a/tests/est_field_parser.py +++ b/tests/test_field_parser.py @@ -420,4 +420,154 @@ def test_day_range_boundaries(self): 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 + assert len(result) == 31 + + +class TestParseMonth: + """Tests for parsing month field.""" + + def setup_method(self): + """Set up test fixtures.""" + self.parser = FieldParser() + + def test_parse_single_month(self): + """Test parsing a single month value.""" + field = self.parser.parse_month("1") + assert field.raw_value == "1" + assert field.field_type == FieldType.MONTH + assert field.parsed_values == {1} + + def test_parse_month_wildcard(self): + """Test parsing month wildcard.""" + field = self.parser.parse_month("*") + assert field.raw_value == "*" + assert field.parsed_values == set(range(1, 13)) + assert len(field.parsed_values) == 12 + + def test_parse_month_range(self): + """Test parsing month range.""" + field = self.parser.parse_month("1-3") + assert field.parsed_values == {1, 2, 3} + + def test_parse_month_list(self): + """Test parsing month list.""" + field = self.parser.parse_month("1,6,12") + assert field.parsed_values == {1, 6, 12} + + def test_parse_month_step_wildcard(self): + """Test parsing month step with wildcard.""" + field = self.parser.parse_month("*/3") + assert field.parsed_values == {1, 4, 7, 10} + + def test_parse_month_step_range(self): + """Test parsing month step with range.""" + field = self.parser.parse_month("1-6/2") + assert field.parsed_values == {1, 3, 5} + + def test_parse_month_complex(self): + """Test parsing complex month expression.""" + field = self.parser.parse_month("1-3,6,9-12/3") + expected = {1, 2, 3, 6, 9, 12} + assert field.parsed_values == expected + + def test_parse_month_max_value(self): + """Test parsing maximum month value.""" + field = self.parser.parse_month("12") + assert field.parsed_values == {12} + + def test_parse_month_out_of_range_high(self): + """Test parsing month value too high.""" + with pytest.raises(FieldError, match="month.*out of range"): + self.parser.parse_month("13") + + def test_parse_month_out_of_range_low(self): + """Test parsing month value too low.""" + with pytest.raises(FieldError, match="month.*out of range"): + self.parser.parse_month("0") + + def test_parse_month_invalid_range(self): + """Test parsing invalid month range.""" + with pytest.raises(FieldError, match="start.*>.*end"): + self.parser.parse_month("6-3") + + def test_parse_month_invalid_step(self): + """Test parsing invalid month step.""" + with pytest.raises(FieldError, match="Step value must be positive"): + self.parser.parse_month("*/0") + + def test_parse_month_empty(self): + """Test parsing empty month field.""" + with pytest.raises(FieldError, match="Empty"): + self.parser.parse_month("") + + def test_parse_month_name_jan(self): + """Test parsing JAN month name.""" + field = self.parser.parse_month("JAN") + assert field.parsed_values == {1} + + def test_parse_month_name_dec(self): + """Test parsing DEC month name.""" + field = self.parser.parse_month("DEC") + assert field.parsed_values == {12} + + def test_parse_month_name_range(self): + """Test parsing month name range.""" + field = self.parser.parse_month("JAN-MAR") + assert field.parsed_values == {1, 2, 3} + + def test_parse_month_name_list(self): + """Test parsing month name list.""" + field = self.parser.parse_month("JAN,JUN,DEC") + assert field.parsed_values == {1, 6, 12} + + def test_parse_month_name_mixed(self): + """Test parsing mixed month names and numbers.""" + field = self.parser.parse_month("1,FEB,MAR,6") + assert field.parsed_values == {1, 2, 3, 6} + + def test_parse_month_name_lowercase(self): + """Test parsing lowercase month names.""" + field = self.parser.parse_month("jan,feb,mar") + assert field.parsed_values == {1, 2, 3} + + def test_parse_month_name_mixed_case(self): + """Test parsing mixed case month names.""" + field = self.parser.parse_month("Jan,Feb,Mar") + assert field.parsed_values == {1, 2, 3} + + def test_parse_month_quarters(self): + """Test parsing quarterly months.""" + field = self.parser.parse_month("1,4,7,10") + assert field.parsed_values == {1, 4, 7, 10} + + def test_parse_month_summer(self): + """Test parsing summer months.""" + field = self.parser.parse_month("JUN-AUG") + assert field.parsed_values == {6, 7, 8} + + def test_parse_month_winter(self): + """Test parsing winter months.""" + field = self.parser.parse_month("DEC,JAN,FEB") + assert field.parsed_values == {1, 2, 12} + + def test_parse_month_step_from_single(self): + """Test parsing step from single month value.""" + field = self.parser.parse_month("3/3") + # Should give 3, 6, 9, 12 + assert field.parsed_values == {3, 6, 9, 12} + + def test_parse_month_duplicates_removed(self): + """Test that duplicate month values are removed.""" + field = self.parser.parse_month("1,1,6,6") + assert field.parsed_values == {1, 6} + + def test_parse_month_all_names(self): + """Test parsing all month names.""" + field = self.parser.parse_month("JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC") + assert field.parsed_values == set(range(1, 13)) + + def test_parse_month_invalid_name(self): + """Test parsing invalid month name.""" + # Invalid names should pass through and fail as invalid numbers + with pytest.raises(FieldError, match="not a number"): + self.parser.parse_month("JANUARY") \ No newline at end of file