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
25 changes: 25 additions & 0 deletions src/cronpal/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand All @@ -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:
Expand Down Expand Up @@ -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())
57 changes: 56 additions & 1 deletion src/cronpal/field_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
17 changes: 10 additions & 7 deletions src/cronpal/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -94,24 +95,26 @@ 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.
"""
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
Expand Down
212 changes: 211 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,4 +345,214 @@ def test_special_string():
output = f.getvalue()
assert result == 0
assert "✓ Valid" in output
assert "@daily" in output
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
Loading
Loading