From 10f896ca28ea202b5f153b9a61c47caecfa482c6 Mon Sep 17 00:00:00 2001 From: Valentin Todorov Date: Mon, 22 Sep 2025 11:42:41 +0300 Subject: [PATCH 1/2] feat: add next run time calculator with multiple runs --- src/cronpal/__init__.py | 2 + src/cronpal/cli.py | 63 ++++++++ src/cronpal/scheduler.py | 36 ++++- tests/test_cli_next.py | 300 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli_next.py diff --git a/src/cronpal/__init__.py b/src/cronpal/__init__.py index a1a17aa..4ea42f6 100644 --- a/src/cronpal/__init__.py +++ b/src/cronpal/__init__.py @@ -15,6 +15,7 @@ from cronpal.field_parser import FieldParser from cronpal.models import CronExpression, CronField, FieldType from cronpal.parser import create_parser +from cronpal.scheduler import CronScheduler from cronpal.special_parser import SpecialStringParser from cronpal.validators import validate_expression @@ -25,6 +26,7 @@ "FieldType", "FieldParser", "SpecialStringParser", + "CronScheduler", "validate_expression", "CronPalError", "InvalidCronExpression", diff --git a/src/cronpal/cli.py b/src/cronpal/cli.py index 5a9261a..564f745 100644 --- a/src/cronpal/cli.py +++ b/src/cronpal/cli.py @@ -2,12 +2,14 @@ """Main CLI entry point for CronPal.""" import sys +from datetime import datetime from cronpal.error_handler import ErrorHandler, suggest_fix from cronpal.exceptions import CronPalError from cronpal.field_parser import FieldParser from cronpal.models import CronExpression from cronpal.parser import create_parser +from cronpal.scheduler import CronScheduler from cronpal.special_parser import SpecialStringParser from cronpal.validators import validate_expression, validate_expression_format @@ -70,6 +72,10 @@ def main(args=None): print(f" Validation: PASSED") _print_verbose_fields(cron_expr) + # Show next run times if requested + if parsed_args.next is not None: + _print_next_runs(cron_expr, parsed_args.next) + return 0 except CronPalError as e: @@ -179,5 +185,62 @@ def _print_day_names(prefix: str, values: set): print(f"{prefix}Days: {', '.join(names)}") +def _print_next_runs(cron_expr: CronExpression, count: int): + """ + Print the next run times for a cron expression. + + Args: + cron_expr: The CronExpression to calculate runs for. + count: Number of next runs to show. + """ + # Don't show next runs for @reboot + if cron_expr.raw_expression.lower() == "@reboot": + print("\nNext runs: @reboot only runs at system startup") + return + + # Make sure we have parsed fields + if not cron_expr.is_valid(): + print("\nNext runs: Cannot calculate - incomplete expression") + return + + try: + scheduler = CronScheduler(cron_expr) + next_runs = scheduler.get_next_runs(count) + + print(f"\nNext {count} run{'s' if count != 1 else ''}:") + for i, run_time in enumerate(next_runs, 1): + # Format the datetime nicely + formatted = run_time.strftime("%Y-%m-%d %H:%M:%S %A") + + # Add relative time for first few entries + if i <= 3: + now = datetime.now() + delta = run_time - now + + if delta.days == 0: + if delta.seconds < 3600: + minutes = delta.seconds // 60 + relative = f"in {minutes} minute{'s' if minutes != 1 else ''}" + else: + hours = delta.seconds // 3600 + relative = f"in {hours} hour{'s' if hours != 1 else ''}" + elif delta.days == 1: + relative = "tomorrow" + elif delta.days < 7: + relative = f"in {delta.days} days" + else: + relative = "" + + if relative: + print(f" {i}. {formatted} ({relative})") + else: + print(f" {i}. {formatted}") + else: + print(f" {i}. {formatted}") + + except Exception as e: + print(f"\nNext runs: Error calculating - {e}") + + if __name__ == "__main__": sys.exit(main()) \ No newline at end of file diff --git a/src/cronpal/scheduler.py b/src/cronpal/scheduler.py index 2de205e..b1ae03d 100644 --- a/src/cronpal/scheduler.py +++ b/src/cronpal/scheduler.py @@ -1,7 +1,7 @@ """Scheduler for calculating cron expression run times.""" from datetime import datetime, timedelta -from typing import Optional +from typing import List, Optional from cronpal.exceptions import CronPalError from cronpal.models import CronExpression @@ -67,6 +67,38 @@ def get_next_run(self, after: Optional[datetime] = None) -> datetime: raise CronPalError("Could not find next run time within reasonable limits") + def get_next_runs(self, count: int, after: Optional[datetime] = None) -> List[datetime]: + """ + Calculate multiple next run times for the cron expression. + + Args: + count: Number of next run times to calculate. + after: The datetime to start searching from. + Defaults to current time if not provided. + + Returns: + List of next run times. + + Raises: + ValueError: If count is less than 1. + """ + if count < 1: + raise ValueError("Count must be at least 1") + + if after is None: + after = datetime.now() + + runs = [] + current = after + + for _ in range(count): + next_run = self.get_next_run(current) + runs.append(next_run) + # Start next search 1 minute after the found time + current = next_run + timedelta(minutes=1) + + return runs + def _matches_time(self, dt: datetime) -> bool: """ Check if a datetime matches the cron expression. @@ -200,7 +232,7 @@ def _get_next_day(self, dt: datetime) -> Optional[datetime]: break test_dt = dt.replace(day=day, hour=first_hour, minute=first_minute, - second=0, microsecond=0) + second=0, microsecond=0) # Check if this day matches day constraints day_of_month_match = day in self.cron_expr.day_of_month.parsed_values diff --git a/tests/test_cli_next.py b/tests/test_cli_next.py new file mode 100644 index 0000000..ee698b8 --- /dev/null +++ b/tests/test_cli_next.py @@ -0,0 +1,300 @@ +"""Tests for CLI next runs functionality.""" + +import subprocess +import sys +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import patch + +import pytest + +# Add src to path for testing +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from cronpal.cli import main + + +class TestCLINextRuns: + """Tests for CLI --next flag functionality.""" + + def test_next_flag_default(self): + """Test --next flag with default value.""" + result = main(["0 0 * * *", "--next", "5"]) + assert result == 0 + + def test_next_flag_single(self): + """Test --next flag with single run.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--next", "1"]) + + output = f.getvalue() + assert result == 0 + assert "Next 1 run:" in output + assert "1." in output + + def test_next_flag_multiple(self): + """Test --next flag with multiple runs.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--next", "3"]) + + output = f.getvalue() + assert result == 0 + assert "Next 3 runs:" in output + assert "1." in output + assert "2." in output + assert "3." in output + + @patch('cronpal.scheduler.datetime') + def test_next_runs_with_fixed_time(self, mock_datetime): + """Test next runs with fixed current time.""" + import io + import contextlib + + # Fix the current time for predictable output + fixed_time = datetime(2024, 1, 15, 10, 30, 0) + mock_datetime.now.return_value = fixed_time + mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs) + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 12 * * *", "--next", "2"]) + + output = f.getvalue() + assert result == 0 + assert "Next 2 runs:" in output + # Should show 12:00 today and tomorrow + assert "2024-01-15 12:00:00" in output + assert "2024-01-16 12:00:00" in output + + def test_next_runs_every_minute(self): + """Test next runs for every minute.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["* * * * *", "--next", "3"]) + + output = f.getvalue() + assert result == 0 + assert "Next 3 runs:" in output + # Should have relative time descriptions + assert "in" in output.lower() or "minute" in output.lower() + + def test_next_runs_with_verbose(self): + """Test next runs with verbose flag.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--next", "2", "--verbose"]) + + output = f.getvalue() + assert result == 0 + # Should show field details AND next runs + assert "Minute field:" in output + assert "Hour field:" in output + assert "Next 2 runs:" in output + + def test_next_runs_special_string(self): + """Test next runs with special string.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@daily", "--next", "3"]) + + output = f.getvalue() + assert result == 0 + assert "Next 3 runs:" in output + # Daily should show midnight times + assert "00:00:00" in output + + def test_next_runs_special_string_reboot(self): + """Test next runs with @reboot.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@reboot", "--next", "5"]) + + output = f.getvalue() + assert result == 0 + assert "@reboot only runs at system startup" in output + + def test_next_runs_hourly(self): + """Test next runs for hourly schedule.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 * * * *", "--next", "3"]) + + output = f.getvalue() + assert result == 0 + assert "Next 3 runs:" in output + # All should be at minute 00 + assert output.count(":00:00") >= 3 + + def test_next_runs_specific_weekday(self): + """Test next runs for specific weekday.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * MON", "--next", "3"]) + + output = f.getvalue() + assert result == 0 + assert "Next 3 runs:" in output + # All should be Mondays + assert output.count("Monday") == 3 + + def test_next_runs_complex_expression(self): + """Test next runs for complex expression.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["*/15 9-17 * * MON-FRI", "--next", "5"]) + + output = f.getvalue() + assert result == 0 + assert "Next 5 runs:" in output + # Should not include weekends + assert "Saturday" not in output + assert "Sunday" not in output + + def test_next_runs_with_month_names(self): + """Test next runs with month names.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 1 JAN,JUL *", "--next", "2"]) + + output = f.getvalue() + assert result == 0 + assert "Next 2 runs:" in output + # Should show January 1 and July 1 + + def test_next_runs_with_day_names(self): + """Test next runs with day names.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * SUN,WED", "--next", "4"]) + + output = f.getvalue() + assert result == 0 + assert "Next 4 runs:" in output + # Should alternate between Sundays and Wednesdays + assert "Sunday" in output + assert "Wednesday" in output + + def test_next_runs_leap_year(self): + """Test next runs including February 29.""" + import io + import contextlib + + # Use a date before Feb 29, 2024 (leap year) + with patch('cronpal.scheduler.datetime') as mock_datetime: + fixed_time = datetime(2024, 2, 1, 0, 0, 0) + mock_datetime.now.return_value = fixed_time + mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs) + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 29 2 *", "--next", "2"]) + + output = f.getvalue() + assert result == 0 + assert "Next 2 runs:" in output + # Should show 2024-02-29 and 2028-02-29 + assert "2024-02-29" in output + + def test_next_runs_with_step_values(self): + """Test next runs with step values.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["*/10 * * * *", "--next", "3"]) + + output = f.getvalue() + assert result == 0 + assert "Next 3 runs:" in output + # Minutes should be 00, 10, 20, 30, 40, or 50 + + def test_next_runs_invalid_expression(self): + """Test next runs with invalid expression.""" + import io + import contextlib + + f_err = io.StringIO() + with contextlib.redirect_stderr(f_err): + result = main(["invalid", "--next", "5"]) + + error_output = f_err.getvalue() + assert result == 1 + # Should show error, not next runs + assert "Invalid" in error_output + + def test_next_runs_relative_time_today(self): + """Test that relative time shows for today.""" + import io + import contextlib + + # Use an expression that will run soon + now = datetime.now() + next_hour = (now.hour + 1) % 24 + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main([f"0 {next_hour} * * *", "--next", "1"]) + + output = f.getvalue() + assert result == 0 + # Should show relative time if it's today + if next_hour > now.hour: + assert "in" in output.lower() + + def test_next_runs_formatting(self): + """Test next runs output formatting.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--next", "5"]) + + output = f.getvalue() + assert result == 0 + # Check formatting + assert "Next 5 runs:" in output + + # Check numbering + for i in range(1, 6): + assert f"{i}." in output + + # Check date format (YYYY-MM-DD HH:MM:SS Weekday) + assert "2" in output # Year starts with 2 + assert ":" in output # Time separator \ No newline at end of file From 3e851069b1cac133e51a2cebce2776b63c4036a1 Mon Sep 17 00:00:00 2001 From: Valentin Todorov Date: Mon, 22 Sep 2025 11:46:28 +0300 Subject: [PATCH 2/2] feat: add next run time calculator with multiple runs - 2 --- src/cronpal/scheduler.py | 48 +++++++++++++++++----------------------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/src/cronpal/scheduler.py b/src/cronpal/scheduler.py index b1ae03d..0acc0db 100644 --- a/src/cronpal/scheduler.py +++ b/src/cronpal/scheduler.py @@ -266,15 +266,29 @@ def _get_next_month(self, dt: datetime) -> datetime: first_hour = valid_hours[0] if valid_hours else 0 first_minute = valid_minutes[0] if valid_minutes else 0 - # Try remaining months this year - for month in valid_months: - if month > dt.month: + # Start searching from current year + current_year = dt.year + current_month = dt.month + + # Search for up to 10 years to handle February 29th cases + for year_offset in range(10): + search_year = current_year + year_offset + + # Determine which months to check this year + if year_offset == 0: + # For current year, only check months after current month + months_to_check = [m for m in valid_months if m > current_month] + else: + # For future years, check all valid months + months_to_check = valid_months + + for month in months_to_check: # Find first valid day in this month for day in range(1, 32): - if not is_valid_day_in_month(dt.year, month, day): - break + if not is_valid_day_in_month(search_year, month, day): + continue - test_dt = datetime(dt.year, month, day, first_hour, first_minute, 0, 0) + test_dt = datetime(search_year, month, day, first_hour, first_minute, 0, 0) # Check day constraints day_of_month_match = day in self.cron_expr.day_of_month.parsed_values @@ -287,27 +301,5 @@ def _get_next_month(self, dt: datetime) -> datetime: if day_of_month_match and day_of_week_match: return test_dt - # No valid month found this year, try next year - next_year = dt.year + 1 - first_month = valid_months[0] - - # Find first valid day in the first month of next year - for day in range(1, 32): - if not is_valid_day_in_month(next_year, first_month, day): - break - - test_dt = datetime(next_year, first_month, day, first_hour, first_minute, 0, 0) - - # Check day constraints - day_of_month_match = day in self.cron_expr.day_of_month.parsed_values - day_of_week_match = get_weekday(test_dt) in self.cron_expr.day_of_week.parsed_values - - if not self.cron_expr.day_of_month.is_wildcard() and not self.cron_expr.day_of_week.is_wildcard(): - if day_of_month_match or day_of_week_match: - return test_dt - else: - if day_of_month_match and day_of_week_match: - return test_dt - # This should rarely happen unless the cron expression is very restrictive raise CronPalError("Could not find valid next month") \ No newline at end of file