From cc2a2c8739e528d3145f462330b5ca4ccccc6e83 Mon Sep 17 00:00:00 2001 From: Valentin Todorov Date: Mon, 22 Sep 2025 17:46:50 +0300 Subject: [PATCH] feat: add timezone support for cron expressions --- pyproject.toml | 4 +- src/cronpal/cli.py | 61 ++++-- src/cronpal/parser.py | 14 ++ src/cronpal/scheduler.py | 68 +++++-- src/cronpal/timezone_utils.py | 239 ++++++++++++++++++++++ tests/test_cli_timezone.py | 329 +++++++++++++++++++++++++++++++ tests/test_scheduler_timezone.py | 315 +++++++++++++++++++++++++++++ tests/test_timezone_utils.py | 310 +++++++++++++++++++++++++++++ 8 files changed, 1312 insertions(+), 28 deletions(-) create mode 100644 src/cronpal/timezone_utils.py create mode 100644 tests/test_cli_timezone.py create mode 100644 tests/test_scheduler_timezone.py create mode 100644 tests/test_timezone_utils.py diff --git a/pyproject.toml b/pyproject.toml index 40a37d7..edda397 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,9 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] -dependencies = [] +dependencies = [ + "pytz>=2023.3", +] [project.optional-dependencies] dev = [ diff --git a/src/cronpal/cli.py b/src/cronpal/cli.py index 43f5ef4..b9c08bd 100644 --- a/src/cronpal/cli.py +++ b/src/cronpal/cli.py @@ -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 @@ -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) @@ -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 @@ -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": @@ -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: @@ -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": @@ -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: diff --git a/src/cronpal/parser.py b/src/cronpal/parser.py index d9e6191..fdbe151 100644 --- a/src/cronpal/parser.py +++ b/src/cronpal/parser.py @@ -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) @@ -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 \ No newline at end of file diff --git a/src/cronpal/scheduler.py b/src/cronpal/scheduler.py index f24ed0a..5977d52 100644 --- a/src/cronpal/scheduler.py +++ b/src/cronpal/scheduler.py @@ -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 @@ -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(): @@ -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) @@ -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. @@ -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 @@ -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) @@ -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. @@ -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 @@ -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 @@ -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. @@ -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. @@ -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 @@ -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 diff --git a/src/cronpal/timezone_utils.py b/src/cronpal/timezone_utils.py new file mode 100644 index 0000000..ea86295 --- /dev/null +++ b/src/cronpal/timezone_utils.py @@ -0,0 +1,239 @@ +"""Timezone utilities for cron expressions.""" + +from datetime import datetime +from typing import List, Optional, Union + +import pytz + + +def get_timezone(tz_name: Optional[str] = None) -> pytz.tzinfo.BaseTzInfo: + """ + Get a timezone object from a timezone name. + + Args: + tz_name: The timezone name (e.g., 'US/Eastern', 'Europe/London'). + If None, returns the system's local timezone. + + Returns: + A timezone object. + + Raises: + ValueError: If the timezone name is invalid. + """ + if tz_name is None: + # Try to get system timezone + try: + import tzlocal + return tzlocal.get_localzone() + except (ImportError, Exception): + # Fall back to UTC if we can't determine local timezone + return pytz.UTC + + try: + return pytz.timezone(tz_name) + except pytz.UnknownTimeZoneError: + raise ValueError(f"Unknown timezone: '{tz_name}'") + + +def convert_to_timezone(dt: datetime, tz: Union[str, pytz.tzinfo.BaseTzInfo]) -> datetime: + """ + Convert a datetime to a specific timezone. + + Args: + dt: The datetime to convert (can be naive or aware). + tz: The target timezone (string name or timezone object). + + Returns: + A timezone-aware datetime in the target timezone. + """ + if isinstance(tz, str): + tz = get_timezone(tz) + + # If datetime is naive, assume it's in the target timezone + if dt.tzinfo is None: + return tz.localize(dt) + + # If datetime is already aware, convert it + return dt.astimezone(tz) + + +def localize_datetime(dt: datetime, tz: Union[str, pytz.tzinfo.BaseTzInfo]) -> datetime: + """ + Localize a naive datetime to a specific timezone. + + Args: + dt: A naive datetime object. + tz: The timezone to localize to (string name or timezone object). + + Returns: + A timezone-aware datetime. + + Raises: + ValueError: If the datetime is already timezone-aware. + """ + if dt.tzinfo is not None: + raise ValueError("Datetime is already timezone-aware") + + if isinstance(tz, str): + tz = get_timezone(tz) + + return tz.localize(dt) + + +def get_current_time(tz: Optional[Union[str, pytz.tzinfo.BaseTzInfo]] = None) -> datetime: + """ + Get the current time in a specific timezone. + + Args: + tz: The timezone (string name or timezone object). + If None, returns current time in local timezone. + + Returns: + Current timezone-aware datetime. + """ + if tz is None: + tz = get_timezone(None) + elif isinstance(tz, str): + tz = get_timezone(tz) + + return datetime.now(tz) + + +def list_common_timezones() -> List[str]: + """ + Get a list of common timezone names. + + Returns: + List of common timezone names. + """ + return pytz.common_timezones + + +def is_valid_timezone(tz_name: str) -> bool: + """ + Check if a timezone name is valid. + + Args: + tz_name: The timezone name to check. + + Returns: + True if the timezone name is valid, False otherwise. + """ + return tz_name in pytz.all_timezones + + +def get_timezone_offset(tz: Union[str, pytz.tzinfo.BaseTzInfo], dt: Optional[datetime] = None) -> str: + """ + Get the UTC offset for a timezone at a specific time. + + Args: + tz: The timezone (string name or timezone object). + dt: The datetime to get the offset for. + If None, uses current time. + + Returns: + UTC offset as a string (e.g., '+05:30', '-08:00'). + """ + if isinstance(tz, str): + tz = get_timezone(tz) + + if dt is None: + dt = datetime.now() + + # Localize the datetime to the timezone + if dt.tzinfo is None: + dt = tz.localize(dt) + else: + dt = dt.astimezone(tz) + + # Get the UTC offset + offset = dt.strftime('%z') + + # Format as +HH:MM or -HH:MM + if offset: + return f"{offset[:3]}:{offset[3:]}" + return "+00:00" + + +def get_timezone_abbreviation(tz: Union[str, pytz.tzinfo.BaseTzInfo], dt: Optional[datetime] = None) -> str: + """ + Get the timezone abbreviation (e.g., 'EST', 'PDT'). + + Args: + tz: The timezone (string name or timezone object). + dt: The datetime to get the abbreviation for. + If None, uses current time. + + Returns: + Timezone abbreviation. + """ + if isinstance(tz, str): + tz = get_timezone(tz) + + if dt is None: + dt = datetime.now() + + # Localize the datetime to the timezone + if dt.tzinfo is None: + dt = tz.localize(dt) + else: + dt = dt.astimezone(tz) + + return dt.strftime('%Z') + + +def format_datetime_with_timezone(dt: datetime, tz: Optional[Union[str, pytz.tzinfo.BaseTzInfo]] = None) -> str: + """ + Format a datetime with timezone information. + + Args: + dt: The datetime to format. + tz: The timezone to convert to before formatting. + If None and dt is naive, uses local timezone. + + Returns: + Formatted datetime string with timezone. + """ + # Convert to target timezone if specified + if tz is not None: + dt = convert_to_timezone(dt, tz) + elif dt.tzinfo is None: + # If naive and no timezone specified, use local + dt = convert_to_timezone(dt, get_timezone(None)) + + # Format with timezone abbreviation and offset + tz_abbrev = dt.strftime('%Z') + tz_offset = dt.strftime('%z') + if tz_offset: + tz_offset = f"{tz_offset[:3]}:{tz_offset[3:]}" + + return dt.strftime(f"%Y-%m-%d %H:%M:%S %A {tz_abbrev} ({tz_offset})") + + +def convert_between_timezones(dt: datetime, from_tz: Union[str, pytz.tzinfo.BaseTzInfo], + to_tz: Union[str, pytz.tzinfo.BaseTzInfo]) -> datetime: + """ + Convert a datetime from one timezone to another. + + Args: + dt: The datetime to convert (should be naive). + from_tz: The source timezone. + to_tz: The target timezone. + + Returns: + Datetime in the target timezone. + """ + if isinstance(from_tz, str): + from_tz = get_timezone(from_tz) + if isinstance(to_tz, str): + to_tz = get_timezone(to_tz) + + # If datetime is naive, localize it to source timezone + if dt.tzinfo is None: + dt = from_tz.localize(dt) + # If it's already aware but not in from_tz, convert to from_tz first + elif dt.tzinfo != from_tz: + dt = dt.astimezone(from_tz) + + # Convert to target timezone + return dt.astimezone(to_tz) \ No newline at end of file diff --git a/tests/test_cli_timezone.py b/tests/test_cli_timezone.py new file mode 100644 index 0000000..835bfc7 --- /dev/null +++ b/tests/test_cli_timezone.py @@ -0,0 +1,329 @@ +"""Tests for CLI timezone functionality.""" + +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from unittest.mock import patch + +import pytest +import pytz + +# Add src to path for testing +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from cronpal.cli import main + + +class TestCLITimezone: + """Tests for CLI timezone functionality.""" + + def test_timezone_flag_utc(self): + """Test --timezone flag with UTC.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--timezone", "UTC"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: UTC" in output + assert "Valid cron expression" in output + + def test_timezone_flag_us_eastern(self): + """Test --timezone flag with US/Eastern.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--timezone", "US/Eastern"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: US/Eastern" in output + assert "EST" in output or "EDT" in output + + def test_timezone_flag_europe_london(self): + """Test --timezone flag with Europe/London.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--timezone", "Europe/London"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: Europe/London" in output + assert "GMT" in output or "BST" in output + + def test_timezone_flag_asia_tokyo(self): + """Test --timezone flag with Asia/Tokyo.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--timezone", "Asia/Tokyo"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: Asia/Tokyo" in output + assert "JST" in output + + def test_timezone_flag_invalid(self): + """Test --timezone flag with invalid timezone.""" + import io + import contextlib + + f_err = io.StringIO() + with contextlib.redirect_stderr(f_err): + result = main(["0 0 * * *", "--timezone", "Invalid/Zone"]) + + error_output = f_err.getvalue() + assert result == 1 + assert "Invalid timezone" in error_output + + def test_timezone_with_next_runs(self): + """Test timezone affects next run calculations.""" + import io + import contextlib + + # Mock datetime to fix the time + with patch('cronpal.timezone_utils.datetime') as mock_datetime: + # Fix time to noon UTC + fixed_time = datetime(2024, 1, 15, 12, 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 14 * * *", "--timezone", "UTC", "--next", "1"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: UTC" in output + assert "Next 1 run:" in output + # Should show 14:00 UTC + + def test_timezone_with_previous_runs(self): + """Test timezone affects previous run calculations.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--timezone", "US/Pacific", "--previous", "1"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: US/Pacific" in output + assert "Previous 1 run" in output + + def test_list_timezones_flag(self): + """Test --list-timezones flag.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["--list-timezones"]) + + output = f.getvalue() + assert result == 0 + assert "Available timezones:" in output + assert "UTC" in output + assert "US/Eastern" in output + assert "Europe/London" in output + assert "Total:" in output + assert "timezones" in output + + def test_timezone_offset_display(self): + """Test that timezone offset is displayed.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--timezone", "US/Eastern"]) + + output = f.getvalue() + assert result == 0 + # Should show offset like (-05:00) or (-04:00) depending on DST + assert "(-0" in output or "(+0" in output + + def test_timezone_abbreviation_display(self): + """Test that timezone abbreviation is displayed.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--timezone", "US/Eastern"]) + + output = f.getvalue() + assert result == 0 + # Should show EST or EDT + assert "EST" in output or "EDT" in output + + def test_timezone_with_verbose(self): + """Test timezone with verbose output.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--timezone", "Europe/Paris", "--verbose"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: Europe/Paris" in output + assert "Minute field:" in output + assert "Hour field:" in output + + def test_timezone_with_special_string(self): + """Test timezone with special string.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@daily", "--timezone", "Asia/Shanghai", "--next", "2"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: Asia/Shanghai" in output + assert "Valid cron expression: @daily" in output + assert "Next 2 runs:" in output + + def test_timezone_affects_relative_time(self): + """Test that timezone affects relative time display.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["* * * * *", "--timezone", "UTC", "--next", "1"]) + + output = f.getvalue() + assert result == 0 + # Should show relative time like "in X minutes" + assert "in" in output.lower() or "minute" in output.lower() + + def test_timezone_with_complex_expression(self): + """Test timezone with complex cron expression.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["*/15 9-17 * * MON-FRI", "--timezone", "US/Central", "--next", "3"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: US/Central" in output + assert "CST" in output or "CDT" in output + + def test_timezone_case_sensitive(self): + """Test that timezone names are case-sensitive.""" + import io + import contextlib + + # Try with wrong case + f_err = io.StringIO() + with contextlib.redirect_stderr(f_err): + result = main(["0 0 * * *", "--timezone", "us/eastern"]) + + error_output = f_err.getvalue() + assert result == 1 + assert "Invalid timezone" in error_output + + def test_timezone_with_dst_transition(self): + """Test timezone handling around DST transitions.""" + import io + import contextlib + + # Use a date around DST transition (March for US) + with patch('cronpal.timezone_utils.datetime') as mock_datetime: + # Set to March 10, 2024 (around DST change) + fixed_time = datetime(2024, 3, 10, 7, 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 2 * * *", "--timezone", "US/Eastern", "--next", "1"]) + + output = f.getvalue() + assert result == 0 + # Should handle DST transition correctly + + def test_timezone_formatting_in_output(self): + """Test that timezone is properly formatted in run times.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 12 * * *", "--timezone", "Australia/Sydney", "--next", "1"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: Australia/Sydney" in output + # Should show AEDT or AEST + assert "AEDT" in output or "AEST" in output + # Should show offset + assert "(+" in output + + def test_timezone_with_both_next_and_previous(self): + """Test timezone with both next and previous runs.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--timezone", "Europe/Berlin", + "--next", "1", "--previous", "1"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: Europe/Berlin" in output + assert "Next 1 run:" in output + assert "Previous 1 run" in output + assert "CET" in output or "CEST" in output + + def test_timezone_with_yearly_expression(self): + """Test timezone with yearly cron expression.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@yearly", "--timezone", "Pacific/Auckland", "--next", "2"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: Pacific/Auckland" in output + assert "NZDT" in output or "NZST" in output + # Should show January 1st in Auckland timezone + + def test_timezone_display_consistency(self): + """Test that timezone display is consistent throughout output.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--timezone", "US/Mountain", + "--next", "2", "--verbose"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: US/Mountain" in output + # All times should show Mountain time zone + assert "MST" in output or "MDT" in output + # Offset should be consistent + assert "(-07:00)" in output or "(-06:00)" in output \ No newline at end of file diff --git a/tests/test_scheduler_timezone.py b/tests/test_scheduler_timezone.py new file mode 100644 index 0000000..3c95e46 --- /dev/null +++ b/tests/test_scheduler_timezone.py @@ -0,0 +1,315 @@ +"""Tests for scheduler timezone functionality.""" + +import sys +from datetime import datetime +from pathlib import Path + +import pytest +import pytz + +# Add src to path for testing +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from cronpal.exceptions import CronPalError +from cronpal.field_parser import FieldParser +from cronpal.models import CronExpression +from cronpal.scheduler import CronScheduler + + +def create_cron_expression(expr_str: str) -> CronExpression: + """Helper to create a parsed CronExpression.""" + fields = expr_str.split() + expr = CronExpression(expr_str) + parser = FieldParser() + + expr.minute = parser.parse_minute(fields[0]) + expr.hour = parser.parse_hour(fields[1]) + expr.day_of_month = parser.parse_day_of_month(fields[2]) + expr.month = parser.parse_month(fields[3]) + expr.day_of_week = parser.parse_day_of_week(fields[4]) + + return expr + + +class TestCronSchedulerTimezone: + """Tests for CronScheduler with timezone support.""" + + def test_scheduler_with_utc(self): + """Test scheduler with UTC timezone.""" + expr = create_cron_expression("0 0 * * *") + scheduler = CronScheduler(expr, "UTC") + + assert scheduler.timezone == pytz.UTC + + def test_scheduler_with_us_eastern(self): + """Test scheduler with US/Eastern timezone.""" + expr = create_cron_expression("0 0 * * *") + scheduler = CronScheduler(expr, "US/Eastern") + + assert scheduler.timezone.zone == "US/Eastern" + + def test_scheduler_with_timezone_object(self): + """Test scheduler with timezone object.""" + expr = create_cron_expression("0 0 * * *") + tz = pytz.timezone("Europe/London") + scheduler = CronScheduler(expr, tz) + + assert scheduler.timezone == tz + + def test_scheduler_without_timezone(self): + """Test scheduler without timezone uses local.""" + expr = create_cron_expression("0 0 * * *") + scheduler = CronScheduler(expr, None) + + assert scheduler.timezone is not None + + def test_next_run_with_timezone(self): + """Test next run calculation with timezone.""" + expr = create_cron_expression("0 12 * * *") + scheduler = CronScheduler(expr, "US/Eastern") + + # Start at 10 AM Eastern + start = pytz.timezone("US/Eastern").localize( + datetime(2024, 1, 15, 10, 0, 0) + ) + next_run = scheduler.get_next_run(start) + + assert next_run.tzinfo.zone == "US/Eastern" + assert next_run.hour == 12 + assert next_run.day == 15 + + def test_next_run_timezone_conversion(self): + """Test next run with timezone conversion.""" + expr = create_cron_expression("0 0 * * *") # Midnight daily + scheduler = CronScheduler(expr, "US/Eastern") + + # Start at 10 PM UTC (5 PM Eastern) + start = pytz.UTC.localize(datetime(2024, 1, 15, 22, 0, 0)) + next_run = scheduler.get_next_run(start) + + # Should be midnight Eastern time + assert next_run.tzinfo.zone == "US/Eastern" + assert next_run.hour == 0 + assert next_run.day == 16 # Next day in Eastern + + def test_previous_run_with_timezone(self): + """Test previous run calculation with timezone.""" + expr = create_cron_expression("0 12 * * *") + scheduler = CronScheduler(expr, "Europe/London") + + # Start at 2 PM London time + start = pytz.timezone("Europe/London").localize( + datetime(2024, 1, 15, 14, 0, 0) + ) + prev_run = scheduler.get_previous_run(start) + + assert prev_run.tzinfo.zone == "Europe/London" + assert prev_run.hour == 12 + assert prev_run.day == 15 + + def test_timezone_aware_datetime_input(self): + """Test with timezone-aware datetime input.""" + expr = create_cron_expression("0 9 * * MON") + scheduler = CronScheduler(expr, "Asia/Tokyo") + + # Start with UTC datetime + start = pytz.UTC.localize(datetime(2024, 1, 16, 0, 0, 0)) # Tuesday UTC + next_run = scheduler.get_next_run(start) + + # Should be next Monday at 9 AM Tokyo time + assert next_run.tzinfo.zone == "Asia/Tokyo" + assert next_run.hour == 9 + assert next_run.weekday() == 0 # Monday + + def test_naive_datetime_input(self): + """Test with naive datetime input.""" + expr = create_cron_expression("30 14 * * *") + scheduler = CronScheduler(expr, "US/Pacific") + + # Start with naive datetime (assumes scheduler's timezone) + start = datetime(2024, 1, 15, 10, 0, 0) + next_run = scheduler.get_next_run(start) + + assert next_run.tzinfo.zone == "US/Pacific" + assert next_run.hour == 14 + assert next_run.minute == 30 + + def test_dst_transition_spring_forward(self): + """Test handling of DST transition (spring forward).""" + expr = create_cron_expression("0 2 * * *") # 2 AM daily + scheduler = CronScheduler(expr, "US/Eastern") + + # Start on March 9, 2024 (DST starts March 10) + start = pytz.timezone("US/Eastern").localize( + datetime(2024, 3, 9, 0, 0, 0) + ) + + runs = scheduler.get_next_runs(3, start) + + # March 9: 2 AM EST exists + assert runs[0].day == 9 + assert runs[0].hour == 2 + + # March 10: 2 AM doesn't exist (skips to 3 AM EDT) + # Scheduler should handle this gracefully + assert runs[1].day == 11 # Skips to March 11 + + # March 11: 2 AM EDT exists + assert runs[2].day == 12 + + def test_dst_transition_fall_back(self): + """Test handling of DST transition (fall back).""" + expr = create_cron_expression("0 1 * * *") # 1 AM daily + scheduler = CronScheduler(expr, "US/Eastern") + + # Start on November 2, 2024 (DST ends November 3) + start = pytz.timezone("US/Eastern").localize( + datetime(2024, 11, 2, 0, 0, 0) + ) + + runs = scheduler.get_next_runs(3, start) + + # Should handle the repeated hour correctly + assert runs[0].day == 2 + assert runs[1].day == 3 + assert runs[2].day == 4 + + def test_multiple_timezones_comparison(self): + """Test same cron in different timezones.""" + expr = create_cron_expression("0 12 * * *") # Noon daily + + # Create schedulers for different timezones + scheduler_utc = CronScheduler(expr, "UTC") + scheduler_eastern = CronScheduler(expr, "US/Eastern") + scheduler_tokyo = CronScheduler(expr, "Asia/Tokyo") + + # Same starting point in UTC + start = pytz.UTC.localize(datetime(2024, 1, 15, 0, 0, 0)) + + # Get next runs + run_utc = scheduler_utc.get_next_run(start) + run_eastern = scheduler_eastern.get_next_run(start) + run_tokyo = scheduler_tokyo.get_next_run(start) + + # All should be at noon in their respective timezones + assert run_utc.hour == 12 + assert run_eastern.hour == 12 + assert run_tokyo.hour == 12 + + # But different when converted to UTC + run_eastern_utc = run_eastern.astimezone(pytz.UTC) + run_tokyo_utc = run_tokyo.astimezone(pytz.UTC) + + assert run_eastern_utc.hour == 17 # 12 PM EST = 5 PM UTC + assert run_tokyo_utc.hour == 3 # 12 PM JST = 3 AM UTC + + def test_get_next_runs_with_timezone(self): + """Test getting multiple next runs with timezone.""" + expr = create_cron_expression("0 */6 * * *") # Every 6 hours + scheduler = CronScheduler(expr, "Australia/Sydney") + + start = pytz.timezone("Australia/Sydney").localize( + datetime(2024, 1, 15, 3, 0, 0) + ) + + runs = scheduler.get_next_runs(4, start) + + assert len(runs) == 4 + assert all(r.tzinfo.zone == "Australia/Sydney" for r in runs) + assert runs[0].hour == 6 + assert runs[1].hour == 12 + assert runs[2].hour == 18 + assert runs[3].hour == 0 # Next day + + def test_get_previous_runs_with_timezone(self): + """Test getting multiple previous runs with timezone.""" + expr = create_cron_expression("30 */4 * * *") # Every 4 hours at 30 minutes + scheduler = CronScheduler(expr, "Europe/Paris") + + start = pytz.timezone("Europe/Paris").localize( + datetime(2024, 1, 15, 10, 0, 0) + ) + + runs = scheduler.get_previous_runs(3, start) + + assert len(runs) == 3 + assert all(r.tzinfo.zone == "Europe/Paris" for r in runs) + assert all(r.minute == 30 for r in runs) + assert runs[0].hour == 8 + assert runs[1].hour == 4 + assert runs[2].hour == 0 + + def test_weekday_calculation_with_timezone(self): + """Test that weekday calculations work correctly with timezones.""" + expr = create_cron_expression("0 0 * * MON") # Midnight on Mondays + scheduler = CronScheduler(expr, "Pacific/Auckland") + + # Start on Sunday in Auckland (still Saturday in some places) + start = pytz.timezone("Pacific/Auckland").localize( + datetime(2024, 1, 14, 12, 0, 0) # Sunday noon + ) + + next_run = scheduler.get_next_run(start) + + # Should be Monday midnight Auckland time + assert next_run.weekday() == 0 # Monday + assert next_run.hour == 0 + assert next_run.tzinfo.zone == "Pacific/Auckland" + + def test_month_boundary_with_timezone(self): + """Test month boundary handling with timezone.""" + expr = create_cron_expression("0 0 1 * *") # First of month at midnight + scheduler = CronScheduler(expr, "US/Hawaii") + + # Start at end of January + start = pytz.timezone("US/Hawaii").localize( + datetime(2024, 1, 31, 12, 0, 0) + ) + + next_run = scheduler.get_next_run(start) + + assert next_run.month == 2 + assert next_run.day == 1 + assert next_run.hour == 0 + assert next_run.tzinfo.zone == "US/Hawaii" + + def test_leap_year_with_timezone(self): + """Test leap year handling with timezone.""" + expr = create_cron_expression("0 0 29 2 *") # Feb 29 + scheduler = CronScheduler(expr, "Europe/Rome") + + # Start in January 2024 (leap year) + start = pytz.timezone("Europe/Rome").localize( + datetime(2024, 1, 1, 0, 0, 0) + ) + + next_run = scheduler.get_next_run(start) + + assert next_run.year == 2024 + assert next_run.month == 2 + assert next_run.day == 29 + assert next_run.tzinfo.zone == "Europe/Rome" + + def test_timezone_offset_changes(self): + """Test that timezone offset changes are handled.""" + expr = create_cron_expression("0 12 * * *") # Noon daily + scheduler = CronScheduler(expr, "US/Eastern") + + # Get runs across DST change + start = pytz.timezone("US/Eastern").localize( + datetime(2024, 3, 9, 0, 0, 0) + ) + + runs = scheduler.get_next_runs(3, start) + + # Convert to UTC to check offset changes + run1_utc = runs[0].astimezone(pytz.UTC) + run2_utc = runs[1].astimezone(pytz.UTC) # After DST starts + run3_utc = runs[2].astimezone(pytz.UTC) + + # Before DST: 12 PM EST = 5 PM UTC + assert run1_utc.hour == 17 + + # After DST: 12 PM EDT = 4 PM UTC + assert run2_utc.hour == 16 + assert run3_utc.hour == 16 \ No newline at end of file diff --git a/tests/test_timezone_utils.py b/tests/test_timezone_utils.py new file mode 100644 index 0000000..507fac9 --- /dev/null +++ b/tests/test_timezone_utils.py @@ -0,0 +1,310 @@ +"""Tests for timezone utilities.""" + +import sys +from datetime import datetime +from pathlib import Path + +import pytest +import pytz + +# Add src to path for testing +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from cronpal.timezone_utils import ( + convert_between_timezones, + convert_to_timezone, + format_datetime_with_timezone, + get_current_time, + get_timezone, + get_timezone_abbreviation, + get_timezone_offset, + is_valid_timezone, + list_common_timezones, + localize_datetime, +) + + +class TestGetTimezone: + """Tests for get_timezone function.""" + + def test_get_timezone_utc(self): + """Test getting UTC timezone.""" + tz = get_timezone("UTC") + assert tz == pytz.UTC + + def test_get_timezone_us_eastern(self): + """Test getting US/Eastern timezone.""" + tz = get_timezone("US/Eastern") + assert tz.zone == "US/Eastern" + + def test_get_timezone_europe_london(self): + """Test getting Europe/London timezone.""" + tz = get_timezone("Europe/London") + assert tz.zone == "Europe/London" + + def test_get_timezone_invalid(self): + """Test getting invalid timezone.""" + with pytest.raises(ValueError, match="Unknown timezone"): + get_timezone("Invalid/Timezone") + + def test_get_timezone_none_returns_default(self): + """Test getting timezone with None returns a timezone.""" + tz = get_timezone(None) + assert tz is not None + # Should be either local timezone or UTC + + +class TestConvertToTimezone: + """Tests for convert_to_timezone function.""" + + def test_convert_naive_to_utc(self): + """Test converting naive datetime to UTC.""" + dt = datetime(2024, 1, 15, 10, 30, 0) + result = convert_to_timezone(dt, "UTC") + + assert result.tzinfo == pytz.UTC + assert result.year == 2024 + assert result.month == 1 + assert result.day == 15 + assert result.hour == 10 + assert result.minute == 30 + + def test_convert_aware_to_different_timezone(self): + """Test converting aware datetime to different timezone.""" + # Create UTC datetime + dt = pytz.UTC.localize(datetime(2024, 1, 15, 10, 30, 0)) + + # Convert to US/Eastern (UTC-5 in January) + result = convert_to_timezone(dt, "US/Eastern") + + assert result.tzinfo.zone == "US/Eastern" + assert result.hour == 5 # 10:30 UTC -> 5:30 EST + + def test_convert_with_timezone_object(self): + """Test converting with timezone object instead of string.""" + dt = datetime(2024, 1, 15, 10, 30, 0) + tz = pytz.timezone("Europe/London") + + result = convert_to_timezone(dt, tz) + assert result.tzinfo.zone == "Europe/London" + + +class TestLocalizeDateTime: + """Tests for localize_datetime function.""" + + def test_localize_naive_datetime(self): + """Test localizing naive datetime.""" + dt = datetime(2024, 1, 15, 10, 30, 0) + result = localize_datetime(dt, "US/Pacific") + + assert result.tzinfo.zone == "US/Pacific" + assert result.year == 2024 + assert result.month == 1 + assert result.day == 15 + assert result.hour == 10 + assert result.minute == 30 + + def test_localize_already_aware_raises_error(self): + """Test localizing already aware datetime raises error.""" + dt = pytz.UTC.localize(datetime(2024, 1, 15, 10, 30, 0)) + + with pytest.raises(ValueError, match="already timezone-aware"): + localize_datetime(dt, "US/Eastern") + + def test_localize_with_timezone_object(self): + """Test localizing with timezone object.""" + dt = datetime(2024, 1, 15, 10, 30, 0) + tz = pytz.timezone("Asia/Tokyo") + + result = localize_datetime(dt, tz) + assert result.tzinfo.zone == "Asia/Tokyo" + + +class TestGetCurrentTime: + """Tests for get_current_time function.""" + + def test_get_current_time_utc(self): + """Test getting current time in UTC.""" + result = get_current_time("UTC") + assert result.tzinfo == pytz.UTC + assert isinstance(result, datetime) + + def test_get_current_time_with_timezone(self): + """Test getting current time with specific timezone.""" + result = get_current_time("US/Eastern") + assert result.tzinfo.zone == "US/Eastern" + + def test_get_current_time_none(self): + """Test getting current time with None timezone.""" + result = get_current_time(None) + assert result.tzinfo is not None + + def test_get_current_time_with_timezone_object(self): + """Test getting current time with timezone object.""" + tz = pytz.timezone("Europe/Paris") + result = get_current_time(tz) + assert result.tzinfo.zone == "Europe/Paris" + + +class TestListCommonTimezones: + """Tests for list_common_timezones function.""" + + def test_list_common_timezones_returns_list(self): + """Test that function returns a list.""" + result = list_common_timezones() + assert isinstance(result, list) + assert len(result) > 0 + + def test_list_common_timezones_contains_major_zones(self): + """Test that list contains major timezones.""" + result = list_common_timezones() + assert "UTC" in result + assert "US/Eastern" in result + assert "Europe/London" in result + assert "Asia/Tokyo" in result + + +class TestIsValidTimezone: + """Tests for is_valid_timezone function.""" + + def test_is_valid_timezone_true(self): + """Test valid timezone names.""" + assert is_valid_timezone("UTC") is True + assert is_valid_timezone("US/Eastern") is True + assert is_valid_timezone("Europe/London") is True + assert is_valid_timezone("Asia/Shanghai") is True + + def test_is_valid_timezone_false(self): + """Test invalid timezone names.""" + assert is_valid_timezone("Invalid") is False + assert is_valid_timezone("US/Invalid") is False + assert is_valid_timezone("NotATimezone") is False + + +class TestGetTimezoneOffset: + """Tests for get_timezone_offset function.""" + + def test_get_timezone_offset_utc(self): + """Test UTC offset.""" + dt = datetime(2024, 1, 15, 10, 0, 0) + result = get_timezone_offset("UTC", dt) + assert result == "+00:00" + + def test_get_timezone_offset_eastern_winter(self): + """Test Eastern timezone offset in winter.""" + dt = datetime(2024, 1, 15, 10, 0, 0) # January - EST + result = get_timezone_offset("US/Eastern", dt) + assert result == "-05:00" + + def test_get_timezone_offset_eastern_summer(self): + """Test Eastern timezone offset in summer.""" + dt = datetime(2024, 7, 15, 10, 0, 0) # July - EDT + result = get_timezone_offset("US/Eastern", dt) + assert result == "-04:00" + + def test_get_timezone_offset_with_timezone_object(self): + """Test offset with timezone object.""" + tz = pytz.timezone("Asia/Tokyo") + dt = datetime(2024, 1, 15, 10, 0, 0) + result = get_timezone_offset(tz, dt) + assert result == "+09:00" + + +class TestGetTimezoneAbbreviation: + """Tests for get_timezone_abbreviation function.""" + + def test_get_timezone_abbreviation_utc(self): + """Test UTC abbreviation.""" + dt = datetime(2024, 1, 15, 10, 0, 0) + result = get_timezone_abbreviation("UTC", dt) + assert result == "UTC" + + def test_get_timezone_abbreviation_eastern_winter(self): + """Test Eastern timezone abbreviation in winter.""" + dt = datetime(2024, 1, 15, 10, 0, 0) # January - EST + result = get_timezone_abbreviation("US/Eastern", dt) + assert result == "EST" + + def test_get_timezone_abbreviation_eastern_summer(self): + """Test Eastern timezone abbreviation in summer.""" + dt = datetime(2024, 7, 15, 10, 0, 0) # July - EDT + result = get_timezone_abbreviation("US/Eastern", dt) + assert result == "EDT" + + def test_get_timezone_abbreviation_with_timezone_object(self): + """Test abbreviation with timezone object.""" + tz = pytz.timezone("Europe/London") + dt = datetime(2024, 1, 15, 10, 0, 0) + result = get_timezone_abbreviation(tz, dt) + assert result in ["GMT", "BST"] + + +class TestFormatDatetimeWithTimezone: + """Tests for format_datetime_with_timezone function.""" + + def test_format_datetime_naive_with_timezone(self): + """Test formatting naive datetime with timezone.""" + dt = datetime(2024, 1, 15, 10, 30, 0) + result = format_datetime_with_timezone(dt, "US/Eastern") + + assert "2024-01-15 10:30:00" in result + assert "Monday" in result + assert "EST" in result + assert "(-05:00)" in result + + def test_format_datetime_aware(self): + """Test formatting aware datetime.""" + dt = pytz.UTC.localize(datetime(2024, 1, 15, 10, 30, 0)) + result = format_datetime_with_timezone(dt) + + assert "2024-01-15 10:30:00" in result + assert "UTC" in result + assert "(+00:00)" in result + + def test_format_datetime_convert_timezone(self): + """Test formatting with timezone conversion.""" + dt = pytz.UTC.localize(datetime(2024, 1, 15, 10, 30, 0)) + result = format_datetime_with_timezone(dt, "US/Eastern") + + assert "2024-01-15 05:30:00" in result # Converted to EST + assert "EST" in result + + +class TestConvertBetweenTimezones: + """Tests for convert_between_timezones function.""" + + def test_convert_between_timezones_naive(self): + """Test converting naive datetime between timezones.""" + dt = datetime(2024, 1, 15, 10, 30, 0) + result = convert_between_timezones(dt, "UTC", "US/Eastern") + + assert result.tzinfo.zone == "US/Eastern" + assert result.hour == 5 # 10:30 UTC -> 5:30 EST + + def test_convert_between_timezones_aware(self): + """Test converting aware datetime between timezones.""" + # Create datetime already in UTC + dt = pytz.UTC.localize(datetime(2024, 1, 15, 10, 30, 0)) + result = convert_between_timezones(dt, "UTC", "US/Eastern") + + assert result.tzinfo.zone == "US/Eastern" + assert result.hour == 5 + + def test_convert_between_timezones_with_objects(self): + """Test converting with timezone objects.""" + dt = datetime(2024, 1, 15, 10, 30, 0) + from_tz = pytz.timezone("Europe/London") + to_tz = pytz.timezone("Asia/Tokyo") + + result = convert_between_timezones(dt, from_tz, to_tz) + assert result.tzinfo.zone == "Asia/Tokyo" + assert result.hour == 19 # 10:30 GMT -> 19:30 JST + + def test_convert_between_timezones_dst(self): + """Test converting between timezones with DST.""" + # Summer date for DST + dt = datetime(2024, 7, 15, 10, 30, 0) + result = convert_between_timezones(dt, "UTC", "US/Eastern") + + assert result.tzinfo.zone == "US/Eastern" + assert result.hour == 6 # 10:30 UTC -> 6:30 EDT (DST) \ No newline at end of file