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
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ classifiers = [
"Programming Language :: Python :: 3.12",
]

dependencies = []
dependencies = [
"pytz>=2023.3",
]

[project.optional-dependencies]
dev = [
Expand Down
61 changes: 49 additions & 12 deletions src/cronpal/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
from cronpal.parser import create_parser
from cronpal.scheduler import CronScheduler
from cronpal.special_parser import SpecialStringParser
from cronpal.timezone_utils import (
format_datetime_with_timezone,
get_current_time,
get_timezone,
get_timezone_abbreviation,
get_timezone_offset,
list_common_timezones,
)
from cronpal.validators import validate_expression, validate_expression_format


Expand All @@ -25,12 +33,33 @@ def main(args=None):
print(f"cronpal {__version__}")
return 0

# Handle list timezones flag
if parsed_args.list_timezones:
print("Available timezones:")
timezones = list_common_timezones()
for tz in timezones:
print(f" {tz}")
print(f"\nTotal: {len(timezones)} timezones")
return 0

# Handle cron expression
if parsed_args.expression:
# Create error handler
error_handler = ErrorHandler(verbose=parsed_args.verbose)

try:
# Get timezone if specified
timezone = None
if parsed_args.timezone:
try:
timezone = get_timezone(parsed_args.timezone)
current_time = get_current_time(timezone)
tz_offset = get_timezone_offset(timezone, current_time)
tz_abbrev = get_timezone_abbreviation(timezone, current_time)
print(f"Using timezone: {parsed_args.timezone} ({tz_abbrev} {tz_offset})")
except ValueError as e:
raise CronPalError(f"Invalid timezone: {e}")

# Validate the expression first
validate_expression(parsed_args.expression)

Expand Down Expand Up @@ -74,11 +103,11 @@ def main(args=None):

# Show next run times if requested
if parsed_args.next is not None:
_print_next_runs(cron_expr, parsed_args.next)
_print_next_runs(cron_expr, parsed_args.next, timezone)

# Show previous run times if requested
if parsed_args.previous is not None:
_print_previous_runs(cron_expr, parsed_args.previous)
_print_previous_runs(cron_expr, parsed_args.previous, timezone)

return 0

Expand Down Expand Up @@ -189,13 +218,14 @@ def _print_day_names(prefix: str, values: set):
print(f"{prefix}Days: {', '.join(names)}")


def _print_next_runs(cron_expr: CronExpression, count: int):
def _print_next_runs(cron_expr: CronExpression, count: int, timezone=None):
"""
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.
timezone: Optional timezone for calculations.
"""
# Don't show next runs for @reboot
if cron_expr.raw_expression.lower() == "@reboot":
Expand All @@ -208,17 +238,20 @@ def _print_next_runs(cron_expr: CronExpression, count: int):
return

try:
scheduler = CronScheduler(cron_expr)
scheduler = CronScheduler(cron_expr, timezone)
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")
# Format the datetime with timezone info
if timezone:
formatted = format_datetime_with_timezone(run_time, timezone)
else:
formatted = run_time.strftime("%Y-%m-%d %H:%M:%S %A")

# Add relative time for first few entries
if i <= 3:
now = datetime.now()
now = get_current_time(timezone)
delta = run_time - now

if delta.days == 0:
Expand Down Expand Up @@ -246,13 +279,14 @@ def _print_next_runs(cron_expr: CronExpression, count: int):
print(f"\nNext runs: Error calculating - {e}")


def _print_previous_runs(cron_expr: CronExpression, count: int):
def _print_previous_runs(cron_expr: CronExpression, count: int, timezone=None):
"""
Print the previous run times for a cron expression.

Args:
cron_expr: The CronExpression to calculate runs for.
count: Number of previous runs to show.
timezone: Optional timezone for calculations.
"""
# Don't show previous runs for @reboot
if cron_expr.raw_expression.lower() == "@reboot":
Expand All @@ -265,17 +299,20 @@ def _print_previous_runs(cron_expr: CronExpression, count: int):
return

try:
scheduler = CronScheduler(cron_expr)
scheduler = CronScheduler(cron_expr, timezone)
previous_runs = scheduler.get_previous_runs(count)

print(f"\nPrevious {count} run{'s' if count != 1 else ''} (most recent first):")
for i, run_time in enumerate(previous_runs, 1):
# Format the datetime nicely
formatted = run_time.strftime("%Y-%m-%d %H:%M:%S %A")
# Format the datetime with timezone info
if timezone:
formatted = format_datetime_with_timezone(run_time, timezone)
else:
formatted = run_time.strftime("%Y-%m-%d %H:%M:%S %A")

# Add relative time for first few entries
if i <= 3:
now = datetime.now()
now = get_current_time(timezone)
delta = now - run_time

if delta.days == 0:
Expand Down
14 changes: 14 additions & 0 deletions src/cronpal/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def create_parser():
cronpal "0 0 * * *" # Parse a cron expression
cronpal --version # Show version
cronpal --help # Show this help message
cronpal "0 0 * * *" --timezone "US/Eastern" # Use specific timezone

Cron Expression Format:
┌───────────── minute (0-59)
Expand Down Expand Up @@ -63,4 +64,17 @@ def create_parser():
help="Show previous N execution times"
)

parser.add_argument(
"-t", "--timezone",
type=str,
metavar="TZ",
help="Timezone for cron execution (e.g., 'US/Eastern', 'Europe/London')"
)

parser.add_argument(
"--list-timezones",
action="store_true",
help="List all available timezone names"
)

return parser
68 changes: 53 additions & 15 deletions src/cronpal/scheduler.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Scheduler for calculating cron expression run times."""

from datetime import datetime, timedelta
from typing import List, Optional
from typing import List, Optional, Union

import pytz

from cronpal.exceptions import CronPalError
from cronpal.models import CronExpression
Expand All @@ -19,21 +21,33 @@
round_to_next_minute,
round_to_previous_minute,
)
from cronpal.timezone_utils import convert_to_timezone, get_current_time, get_timezone


class CronScheduler:
"""Calculator for cron expression run times."""

def __init__(self, cron_expr: CronExpression):
def __init__(self, cron_expr: CronExpression, timezone: Optional[Union[str, pytz.tzinfo.BaseTzInfo]] = None):
"""
Initialize the scheduler with a cron expression.

Args:
cron_expr: The CronExpression to calculate times for.
timezone: The timezone to use for calculations.
Can be a string (e.g., 'US/Eastern') or timezone object.
If None, uses system local timezone.
"""
self.cron_expr = cron_expr
self._validate_expression()

# Set timezone
if timezone is None:
self.timezone = get_timezone(None)
elif isinstance(timezone, str):
self.timezone = get_timezone(timezone)
else:
self.timezone = timezone

def _validate_expression(self):
"""Validate that the expression has all required fields."""
if not self.cron_expr.is_valid():
Expand All @@ -45,13 +59,17 @@ def get_next_run(self, after: Optional[datetime] = None) -> datetime:

Args:
after: The datetime to start searching from.
Can be naive (will use scheduler's timezone) or aware.
Defaults to current time if not provided.

Returns:
The next datetime when the cron expression will run.
The next datetime when the cron expression will run (timezone-aware).
"""
if after is None:
after = datetime.now()
after = get_current_time(self.timezone)
else:
# Convert to scheduler's timezone if needed
after = convert_to_timezone(after, self.timezone)

# Round up to next minute if needed
current = round_to_next_minute(after)
Expand Down Expand Up @@ -79,10 +97,11 @@ def get_next_runs(self, count: int, after: Optional[datetime] = None) -> List[da
Args:
count: Number of next run times to calculate.
after: The datetime to start searching from.
Can be naive (will use scheduler's timezone) or aware.
Defaults to current time if not provided.

Returns:
List of next run times.
List of next run times (all timezone-aware).

Raises:
ValueError: If count is less than 1.
Expand All @@ -91,7 +110,9 @@ def get_next_runs(self, count: int, after: Optional[datetime] = None) -> List[da
raise ValueError("Count must be at least 1")

if after is None:
after = datetime.now()
after = get_current_time(self.timezone)
else:
after = convert_to_timezone(after, self.timezone)

runs = []
current = after
Expand All @@ -110,13 +131,17 @@ def get_previous_run(self, before: Optional[datetime] = None) -> datetime:

Args:
before: The datetime to start searching from.
Can be naive (will use scheduler's timezone) or aware.
Defaults to current time if not provided.

Returns:
The previous datetime when the cron expression ran.
The previous datetime when the cron expression ran (timezone-aware).
"""
if before is None:
before = datetime.now()
before = get_current_time(self.timezone)
else:
# Convert to scheduler's timezone if needed
before = convert_to_timezone(before, self.timezone)

# Round down to previous minute if needed
current = round_to_previous_minute(before)
Expand Down Expand Up @@ -144,10 +169,11 @@ def get_previous_runs(self, count: int, before: Optional[datetime] = None) -> Li
Args:
count: Number of previous run times to calculate.
before: The datetime to start searching from.
Can be naive (will use scheduler's timezone) or aware.
Defaults to current time if not provided.

Returns:
List of previous run times (most recent first).
List of previous run times (most recent first, all timezone-aware).

Raises:
ValueError: If count is less than 1.
Expand All @@ -156,7 +182,9 @@ def get_previous_runs(self, count: int, before: Optional[datetime] = None) -> Li
raise ValueError("Count must be at least 1")

if before is None:
before = datetime.now()
before = get_current_time(self.timezone)
else:
before = convert_to_timezone(before, self.timezone)

runs = []
current = before
Expand All @@ -174,11 +202,15 @@ def _matches_time(self, dt: datetime) -> bool:
Check if a datetime matches the cron expression.

Args:
dt: The datetime to check.
dt: The datetime to check (should be in scheduler's timezone).

Returns:
True if the datetime matches all cron fields.
"""
# Ensure datetime is in the scheduler's timezone
if dt.tzinfo != self.timezone:
dt = convert_to_timezone(dt, self.timezone)

# Check minute
if dt.minute not in self.cron_expr.minute.parsed_values:
return False
Expand Down Expand Up @@ -213,7 +245,7 @@ def _advance_to_next_possible(self, dt: datetime) -> datetime:
Advance datetime to the next possible matching time.

Args:
dt: The current datetime.
dt: The current datetime (in scheduler's timezone).

Returns:
The next datetime that could potentially match.
Expand Down Expand Up @@ -241,7 +273,7 @@ def _retreat_to_previous_possible(self, dt: datetime) -> datetime:
Retreat datetime to the previous possible matching time.

Args:
dt: The current datetime.
dt: The current datetime (in scheduler's timezone).

Returns:
The previous datetime that could potentially match.
Expand Down Expand Up @@ -465,7 +497,10 @@ def _get_next_month(self, dt: datetime) -> datetime:
if not is_valid_day_in_month(search_year, month, day):
continue

test_dt = datetime(search_year, month, day, first_hour, first_minute, 0, 0)
# Create datetime in the scheduler's timezone
test_dt = self.timezone.localize(
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
Expand Down Expand Up @@ -521,7 +556,10 @@ def _get_previous_month(self, dt: datetime) -> datetime:
if not is_valid_day_in_month(search_year, month, day):
continue

test_dt = datetime(search_year, month, day, last_hour, last_minute, 0, 0)
# Create datetime in the scheduler's timezone
test_dt = self.timezone.localize(
datetime(search_year, month, day, last_hour, last_minute, 0, 0)
)

# Check day constraints
day_of_month_match = day in self.cron_expr.day_of_month.parsed_values
Expand Down
Loading
Loading