diff --git a/src/cronpal/__init__.py b/src/cronpal/__init__.py index 4ea42f6..157e686 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.pretty_printer import PrettyPrinter from cronpal.scheduler import CronScheduler from cronpal.special_parser import SpecialStringParser from cronpal.validators import validate_expression @@ -27,6 +28,7 @@ "FieldParser", "SpecialStringParser", "CronScheduler", + "PrettyPrinter", "validate_expression", "CronPalError", "InvalidCronExpression", diff --git a/src/cronpal/cli.py b/src/cronpal/cli.py index b9c08bd..e8f4731 100644 --- a/src/cronpal/cli.py +++ b/src/cronpal/cli.py @@ -9,6 +9,7 @@ from cronpal.field_parser import FieldParser from cronpal.models import CronExpression from cronpal.parser import create_parser +from cronpal.pretty_printer import PrettyPrinter from cronpal.scheduler import CronScheduler from cronpal.special_parser import SpecialStringParser from cronpal.timezone_utils import ( @@ -69,16 +70,33 @@ def main(args=None): # Parse as special string cron_expr = special_parser.parse(parsed_args.expression) - print(f"✔ Valid cron expression: {cron_expr}") + if parsed_args.pretty: + # Pretty print mode + if cron_expr.raw_expression.lower() == "@reboot": + print("\n✔ Valid cron expression: @reboot") + print("\nThis expression runs at system startup/reboot only.") + else: + printer = PrettyPrinter(cron_expr) + print() + print(printer.print_table()) + print() + print(f"Summary: {printer.get_summary()}") + + if parsed_args.verbose: + print() + print(printer.print_detailed()) + else: + # Normal output + print(f"✔ Valid cron expression: {cron_expr}") - if parsed_args.verbose: - print(f" Special string: {cron_expr.raw_expression}") - description = special_parser.get_description(cron_expr.raw_expression) - print(f" Description: {description}") + if parsed_args.verbose: + print(f" Special string: {cron_expr.raw_expression}") + description = special_parser.get_description(cron_expr.raw_expression) + print(f" Description: {description}") - # For @reboot, we don't have fields to show - if cron_expr.raw_expression.lower() != "@reboot": - _print_verbose_fields(cron_expr) + # For @reboot, we don't have fields to show + if cron_expr.raw_expression.lower() != "@reboot": + _print_verbose_fields(cron_expr) else: # Parse the expression into fields fields = validate_expression_format(parsed_args.expression) @@ -94,12 +112,25 @@ def main(args=None): cron_expr.month = field_parser.parse_month(fields[3]) cron_expr.day_of_week = field_parser.parse_day_of_week(fields[4]) - print(f"✔ Valid cron expression: {cron_expr}") + if parsed_args.pretty: + # Pretty print mode + printer = PrettyPrinter(cron_expr) + print() + print(printer.print_table()) + print() + print(f"Summary: {printer.get_summary()}") + + if parsed_args.verbose: + print() + print(printer.print_detailed()) + else: + # Normal output + print(f"✔ Valid cron expression: {cron_expr}") - if parsed_args.verbose: - print(f" Raw expression: {cron_expr.raw_expression}") - print(f" Validation: PASSED") - _print_verbose_fields(cron_expr) + if parsed_args.verbose: + print(f" Raw expression: {cron_expr.raw_expression}") + print(f" Validation: PASSED") + _print_verbose_fields(cron_expr) # Show next run times if requested if parsed_args.next is not None: diff --git a/src/cronpal/parser.py b/src/cronpal/parser.py index fdbe151..69c09ad 100644 --- a/src/cronpal/parser.py +++ b/src/cronpal/parser.py @@ -18,6 +18,7 @@ def create_parser(): cronpal --version # Show version cronpal --help # Show this help message cronpal "0 0 * * *" --timezone "US/Eastern" # Use specific timezone + cronpal "0 0 * * *" --pretty # Pretty print the expression Cron Expression Format: ┌───────────── minute (0-59) @@ -77,4 +78,10 @@ def create_parser(): help="List all available timezone names" ) + parser.add_argument( + "--pretty", + action="store_true", + help="Pretty print the cron expression with formatted output" + ) + return parser \ No newline at end of file diff --git a/src/cronpal/pretty_printer.py b/src/cronpal/pretty_printer.py new file mode 100644 index 0000000..2424308 --- /dev/null +++ b/src/cronpal/pretty_printer.py @@ -0,0 +1,447 @@ +"""Pretty printer for cron expressions.""" + +from typing import List, Optional, Set + +from cronpal.models import CronExpression, CronField, FieldType + + +class PrettyPrinter: + """Pretty printer for cron expressions.""" + + def __init__(self, expression: CronExpression): + """ + Initialize the pretty printer. + + Args: + expression: The CronExpression to pretty print. + """ + self.expression = expression + + def print_table(self) -> str: + """ + Generate a formatted table of the cron expression. + + Returns: + A string containing the formatted table. + """ + lines = [] + + # Header + lines.append("┌" + "─" * 78 + "┐") + lines.append(f"│ {'Cron Expression Analysis':^76} │") + lines.append("├" + "─" * 78 + "┤") + lines.append(f"│ Expression: {self.expression.raw_expression:<63} │") + lines.append("├" + "─" * 17 + "┬" + "─" * 15 + "┬" + "─" * 44 + "┤") + lines.append("│ Field │ Value │ Description │") + lines.append("├" + "─" * 17 + "┼" + "─" * 15 + "┼" + "─" * 44 + "┤") + + # Fields + if self.expression.minute: + lines.append(self._format_field_row("Minute", self.expression.minute)) + + if self.expression.hour: + lines.append(self._format_field_row("Hour", self.expression.hour)) + + if self.expression.day_of_month: + lines.append(self._format_field_row("Day of Month", self.expression.day_of_month)) + + if self.expression.month: + lines.append(self._format_field_row("Month", self.expression.month)) + + if self.expression.day_of_week: + lines.append(self._format_field_row("Day of Week", self.expression.day_of_week)) + + # Footer + lines.append("└" + "─" * 17 + "┴" + "─" * 15 + "┴" + "─" * 44 + "┘") + + return "\n".join(lines) + + def print_simple(self) -> str: + """ + Generate a simple formatted output of the cron expression. + + Returns: + A string containing the simple formatted output. + """ + lines = [] + lines.append(f"Cron Expression: {self.expression.raw_expression}") + lines.append("-" * 50) + + if self.expression.minute: + lines.append(f"Minute: {self.expression.minute.raw_value:10} {self._describe_field(self.expression.minute)}") + + if self.expression.hour: + lines.append(f"Hour: {self.expression.hour.raw_value:10} {self._describe_field(self.expression.hour)}") + + if self.expression.day_of_month: + lines.append(f"Day of Month: {self.expression.day_of_month.raw_value:10} {self._describe_field(self.expression.day_of_month)}") + + if self.expression.month: + lines.append(f"Month: {self.expression.month.raw_value:10} {self._describe_field(self.expression.month)}") + + if self.expression.day_of_week: + lines.append(f"Day of Week: {self.expression.day_of_week.raw_value:10} {self._describe_field(self.expression.day_of_week)}") + + return "\n".join(lines) + + def print_detailed(self) -> str: + """ + Generate a detailed output with parsed values. + + Returns: + A string containing the detailed output. + """ + lines = [] + lines.append("═" * 80) + lines.append(f" CRON EXPRESSION: {self.expression.raw_expression}") + lines.append("═" * 80) + + if self.expression.minute: + lines.extend(self._format_detailed_field("MINUTE", self.expression.minute)) + + if self.expression.hour: + lines.extend(self._format_detailed_field("HOUR", self.expression.hour)) + + if self.expression.day_of_month: + lines.extend(self._format_detailed_field("DAY OF MONTH", self.expression.day_of_month)) + + if self.expression.month: + lines.extend(self._format_detailed_field("MONTH", self.expression.month)) + + if self.expression.day_of_week: + lines.extend(self._format_detailed_field("DAY OF WEEK", self.expression.day_of_week)) + + lines.append("═" * 80) + + return "\n".join(lines) + + def get_summary(self) -> str: + """ + Generate a human-readable summary of when the cron runs. + + Returns: + A string with a human-readable description. + """ + parts = [] + + # Check for common patterns + if self._is_every_minute(): + return "Runs every minute" + + if self._is_hourly(): + minute = list(self.expression.minute.parsed_values)[0] if self.expression.minute else 0 + return f"Runs every hour at minute {minute:02d}" + + if self._is_daily(): + hour = list(self.expression.hour.parsed_values)[0] if self.expression.hour else 0 + minute = list(self.expression.minute.parsed_values)[0] if self.expression.minute else 0 + return f"Runs daily at {hour:02d}:{minute:02d}" + + if self._is_weekly(): + day = self._get_single_weekday_name() + hour = list(self.expression.hour.parsed_values)[0] if self.expression.hour else 0 + minute = list(self.expression.minute.parsed_values)[0] if self.expression.minute else 0 + return f"Runs every {day} at {hour:02d}:{minute:02d}" + + if self._is_monthly(): + day = list(self.expression.day_of_month.parsed_values)[0] if self.expression.day_of_month else 1 + hour = list(self.expression.hour.parsed_values)[0] if self.expression.hour else 0 + minute = list(self.expression.minute.parsed_values)[0] if self.expression.minute else 0 + return f"Runs on day {day} of every month at {hour:02d}:{minute:02d}" + + if self._is_yearly(): + month = list(self.expression.month.parsed_values)[0] if self.expression.month else 1 + day = list(self.expression.day_of_month.parsed_values)[0] if self.expression.day_of_month else 1 + hour = list(self.expression.hour.parsed_values)[0] if self.expression.hour else 0 + minute = list(self.expression.minute.parsed_values)[0] if self.expression.minute else 0 + month_name = self._get_month_name(month) + return f"Runs on {month_name} {day} at {hour:02d}:{minute:02d}" + + # Complex expression - build description from parts + time_part = self._describe_time() + date_part = self._describe_date() + + if time_part and date_part: + return f"Runs {time_part} {date_part}" + elif time_part: + return f"Runs {time_part}" + elif date_part: + return f"Runs {date_part}" + else: + return "Complex schedule" + + def _format_field_row(self, name: str, field: CronField) -> str: + """Format a single field row for the table.""" + description = self._describe_field(field) + # Truncate description if too long + if len(description) > 42: + description = description[:39] + "..." + + return f"│ {name:<15} │ {field.raw_value:<13} │ {description:<42} │" + + def _format_detailed_field(self, name: str, field: CronField) -> List[str]: + """Format detailed field information.""" + lines = [] + lines.append("") + lines.append(f"▸ {name}") + lines.append(" " + "─" * 76) + lines.append(f" Raw Value: {field.raw_value}") + lines.append(f" Range: {field.field_range.min_value}-{field.field_range.max_value}") + lines.append(f" Description: {self._describe_field(field)}") + + if field.parsed_values: + values_str = self._format_value_list(field.parsed_values, field.field_type) + lines.append(f" Values: {values_str}") + + return lines + + def _format_value_list(self, values: Set[int], field_type: FieldType) -> str: + """Format a list of values for display.""" + sorted_values = sorted(values) + + if len(sorted_values) > 20: + # Show first 10 and last 5 with ellipsis + first_part = sorted_values[:10] + last_part = sorted_values[-5:] + + first_str = ", ".join(str(v) for v in first_part) + last_str = ", ".join(str(v) for v in last_part) + return f"{first_str} ... {last_str} ({len(sorted_values)} values)" + else: + if field_type == FieldType.MONTH: + return ", ".join(f"{v} ({self._get_month_name(v)})" for v in sorted_values) + elif field_type == FieldType.DAY_OF_WEEK: + return ", ".join(f"{v} ({self._get_weekday_name(v)})" for v in sorted_values) + else: + return ", ".join(str(v) for v in sorted_values) + + def _describe_field(self, field: CronField) -> str: + """Generate a human-readable description of a field.""" + if field.is_wildcard(): + return f"Every {self._get_field_name(field.field_type)}" + + if not field.parsed_values: + return field.raw_value + + values = field.parsed_values + + # Check for special patterns + # Check if it's an actual step pattern from the raw value + if "/" in field.raw_value: + step = self._get_step_value(values) + if "*/" in field.raw_value: + return f"Every {step} {self._get_field_name_plural(field.field_type)}" + else: + return f"Every {step} {self._get_field_name_plural(field.field_type)} from {min(values)}" + + if self._is_range(values) and "-" in field.raw_value and "/" not in field.raw_value: + min_val = min(values) + max_val = max(values) + return f"From {min_val} to {max_val}" + + if len(values) == 1: + val = list(values)[0] + if field.field_type == FieldType.MONTH: + return self._get_month_name(val) + elif field.field_type == FieldType.DAY_OF_WEEK: + return self._get_weekday_name(val) + else: + return f"At {self._get_field_name(field.field_type)} {val}" + + if len(values) <= 5: + if field.field_type == FieldType.MONTH: + names = [self._get_month_name(v) for v in sorted(values)] + return ", ".join(names) + elif field.field_type == FieldType.DAY_OF_WEEK: + names = [self._get_weekday_name(v) for v in sorted(values)] + return ", ".join(names) + else: + return f"At {self._get_field_name_plural(field.field_type)} " + ", ".join(str(v) for v in sorted(values)) + + return f"{len(values)} selected {self._get_field_name_plural(field.field_type)}" + + def _describe_time(self) -> str: + """Describe the time portion of the cron expression.""" + if not self.expression.minute or not self.expression.hour: + return "" + + minute_vals = self.expression.minute.parsed_values + hour_vals = self.expression.hour.parsed_values + + if len(minute_vals) == 1 and len(hour_vals) == 1: + minute = list(minute_vals)[0] + hour = list(hour_vals)[0] + return f"at {hour:02d}:{minute:02d}" + + if len(minute_vals) == 1: + minute = list(minute_vals)[0] + return f"at minute {minute:02d} of selected hours" + + if len(hour_vals) == 1: + hour = list(hour_vals)[0] + return f"at selected minutes of hour {hour}" + + return "at selected times" + + def _describe_date(self) -> str: + """Describe the date portion of the cron expression.""" + parts = [] + + if self.expression.day_of_month and not self.expression.day_of_month.is_wildcard(): + days = self.expression.day_of_month.parsed_values + if len(days) == 1: + parts.append(f"on day {list(days)[0]}") + else: + parts.append(f"on days {self._format_short_list(days)}") + + if self.expression.month and not self.expression.month.is_wildcard(): + months = self.expression.month.parsed_values + if len(months) == 1: + parts.append(f"in {self._get_month_name(list(months)[0])}") + else: + month_names = [self._get_month_name(m) for m in sorted(months)[:3]] + if len(months) > 3: + parts.append(f"in {', '.join(month_names)}...") + else: + parts.append(f"in {', '.join(month_names)}") + + if self.expression.day_of_week and not self.expression.day_of_week.is_wildcard(): + weekdays = self.expression.day_of_week.parsed_values + if len(weekdays) == 1: + parts.append(f"on {self._get_weekday_name(list(weekdays)[0])}") + else: + day_names = [self._get_weekday_name(d) for d in sorted(weekdays)[:3]] + if len(weekdays) > 3: + parts.append(f"on {', '.join(day_names)}...") + else: + parts.append(f"on {', '.join(day_names)}") + + return " ".join(parts) + + def _format_short_list(self, values: Set[int]) -> str: + """Format a short list of values.""" + sorted_vals = sorted(values) + if len(sorted_vals) <= 5: + return ", ".join(str(v) for v in sorted_vals) + else: + return f"{sorted_vals[0]}, {sorted_vals[1]}, ... {sorted_vals[-1]}" + + def _is_range(self, values: Set[int]) -> bool: + """Check if values form a continuous range.""" + if len(values) < 2: + return False + sorted_vals = sorted(values) + return sorted_vals == list(range(sorted_vals[0], sorted_vals[-1] + 1)) + + def _is_step(self, values: Set[int], min_val: int, max_val: int) -> bool: + """Check if values form a step pattern.""" + if len(values) < 2: + return False + sorted_vals = sorted(values) + if len(sorted_vals) < 2: + return False + + step = sorted_vals[1] - sorted_vals[0] + if step <= 0: + return False + + for i in range(1, len(sorted_vals)): + if sorted_vals[i] - sorted_vals[i-1] != step: + return False + + return True + + def _get_step_value(self, values: Set[int]) -> int: + """Get the step value from a set of values.""" + sorted_vals = sorted(values) + if len(sorted_vals) >= 2: + return sorted_vals[1] - sorted_vals[0] + return 1 + + def _is_every_minute(self) -> bool: + """Check if expression runs every minute.""" + return (self.expression.minute and self.expression.minute.is_wildcard() and + self.expression.hour and self.expression.hour.is_wildcard() and + self.expression.day_of_month and self.expression.day_of_month.is_wildcard() and + self.expression.month and self.expression.month.is_wildcard() and + self.expression.day_of_week and self.expression.day_of_week.is_wildcard()) + + def _is_hourly(self) -> bool: + """Check if expression runs hourly.""" + return (self.expression.minute and len(self.expression.minute.parsed_values) == 1 and + self.expression.hour and self.expression.hour.is_wildcard() and + self.expression.day_of_month and self.expression.day_of_month.is_wildcard() and + self.expression.month and self.expression.month.is_wildcard() and + self.expression.day_of_week and self.expression.day_of_week.is_wildcard()) + + def _is_daily(self) -> bool: + """Check if expression runs daily.""" + return (self.expression.minute and len(self.expression.minute.parsed_values) == 1 and + self.expression.hour and len(self.expression.hour.parsed_values) == 1 and + self.expression.day_of_month and self.expression.day_of_month.is_wildcard() and + self.expression.month and self.expression.month.is_wildcard() and + self.expression.day_of_week and self.expression.day_of_week.is_wildcard()) + + def _is_weekly(self) -> bool: + """Check if expression runs weekly.""" + return (self.expression.minute and len(self.expression.minute.parsed_values) == 1 and + self.expression.hour and len(self.expression.hour.parsed_values) == 1 and + self.expression.day_of_month and self.expression.day_of_month.is_wildcard() and + self.expression.month and self.expression.month.is_wildcard() and + self.expression.day_of_week and len(self.expression.day_of_week.parsed_values) == 1) + + def _is_monthly(self) -> bool: + """Check if expression runs monthly.""" + return (self.expression.minute and len(self.expression.minute.parsed_values) == 1 and + self.expression.hour and len(self.expression.hour.parsed_values) == 1 and + self.expression.day_of_month and len(self.expression.day_of_month.parsed_values) == 1 and + self.expression.month and self.expression.month.is_wildcard() and + self.expression.day_of_week and self.expression.day_of_week.is_wildcard()) + + def _is_yearly(self) -> bool: + """Check if expression runs yearly.""" + return (self.expression.minute and len(self.expression.minute.parsed_values) == 1 and + self.expression.hour and len(self.expression.hour.parsed_values) == 1 and + self.expression.day_of_month and len(self.expression.day_of_month.parsed_values) == 1 and + self.expression.month and len(self.expression.month.parsed_values) == 1 and + self.expression.day_of_week and self.expression.day_of_week.is_wildcard()) + + def _get_field_name(self, field_type: FieldType) -> str: + """Get human-readable field name.""" + names = { + FieldType.MINUTE: "minute", + FieldType.HOUR: "hour", + FieldType.DAY_OF_MONTH: "day", + FieldType.MONTH: "month", + FieldType.DAY_OF_WEEK: "day_of_week" + } + return names.get(field_type, field_type.value) + + def _get_field_name_plural(self, field_type: FieldType) -> str: + """Get human-readable plural field name.""" + names = { + FieldType.MINUTE: "minutes", + FieldType.HOUR: "hours", + FieldType.DAY_OF_MONTH: "days", + FieldType.MONTH: "months", + FieldType.DAY_OF_WEEK: "days_of_week" + } + return names.get(field_type, field_type.value + "s") + + def _get_month_name(self, month: int) -> str: + """Get month name from number.""" + months = ["", "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"] + return months[month] if 0 < month <= 12 else str(month) + + def _get_weekday_name(self, day: int) -> str: + """Get weekday name from number.""" + days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + return days[day] if 0 <= day <= 6 else str(day) + + def _get_single_weekday_name(self) -> str: + """Get the single weekday name if only one is selected.""" + if self.expression.day_of_week and len(self.expression.day_of_week.parsed_values) == 1: + day = list(self.expression.day_of_week.parsed_values)[0] + return self._get_weekday_name(day) + return "" \ No newline at end of file diff --git a/tests/demo_pretty.py b/tests/demo_pretty.py new file mode 100644 index 0000000..1c1d782 --- /dev/null +++ b/tests/demo_pretty.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Demo script for testing pretty print functionality.""" + +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from cronpal.cli import main + +# Test various expressions with pretty print +expressions = [ + "0 0 * * *", # Daily at midnight + "*/15 * * * *", # Every 15 minutes + "0 9-17 * * MON-FRI", # Business hours on weekdays + "0 0 1 * *", # First of every month + "@weekly", # Weekly (special string) + "30 2 15 JAN,JUL *", # Specific dates with month names + "0 */4 * * *", # Every 4 hours +] + +print("CronPal Pretty Print Demo") +print("=" * 80) + +for expr in expressions: + print(f"\nExpression: {expr}") + print("-" * 40) + main([expr, "--pretty"]) + print("\n" + "=" * 80) \ No newline at end of file diff --git a/tests/test_cli_pretty.py b/tests/test_cli_pretty.py new file mode 100644 index 0000000..56e1c34 --- /dev/null +++ b/tests/test_cli_pretty.py @@ -0,0 +1,384 @@ +"""Tests for CLI pretty print functionality.""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +# Add src to path for testing +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from cronpal.cli import main + + +class TestCLIPrettyPrint: + """Tests for CLI --pretty flag functionality.""" + + def test_pretty_flag_basic(self): + """Test --pretty flag with basic expression.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "┌" in output # Table border + assert "│" in output # Table border + assert "└" in output # Table border + assert "Cron Expression Analysis" in output + assert "Field" in output + assert "Value" in output + assert "Description" in output + assert "Summary: Runs daily at 00:00" in output + + def test_pretty_flag_with_every_minute(self): + """Test --pretty flag with every minute expression.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["* * * * *", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "Summary: Runs every minute" in output + + def test_pretty_flag_with_hourly(self): + """Test --pretty flag with hourly expression.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["30 * * * *", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "Summary: Runs every hour at minute 30" in output + + def test_pretty_flag_with_ranges(self): + """Test --pretty flag with range expressions.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 9-17 * * *", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "9-17" in output + assert "From 9 to 17" in output + + def test_pretty_flag_with_lists(self): + """Test --pretty flag with list expressions.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0,15,30,45 12 * * *", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "0,15,30,45" in output + assert "At minutes 0, 15, 30, 45" in output + + def test_pretty_flag_with_steps(self): + """Test --pretty flag with step expressions.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["*/10 * * * *", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "*/10" in output + assert "Every 10 minutes" in output + + def test_pretty_flag_with_month_names(self): + """Test --pretty flag with month names.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 1 JAN,JUL *", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "JAN,JUL" in output + assert "January" in output or "July" in output + + def test_pretty_flag_with_day_names(self): + """Test --pretty flag with day names.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * MON-FRI", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "MON-FRI" in output + assert "From 1 to 5" in output or "Monday" in output + + def test_pretty_flag_with_verbose(self): + """Test --pretty flag with --verbose.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--pretty", "--verbose"]) + + output = f.getvalue() + assert result == 0 + # Should have both table and detailed output + assert "Cron Expression Analysis" in output # Table + assert "═" * 80 in output # Detailed header + assert "CRON EXPRESSION: 0 0 * * *" in output + assert "▸ MINUTE" in output + assert "Raw Value:" in output + assert "Range:" in output + assert "Values:" in output + + def test_pretty_flag_with_special_string_daily(self): + """Test --pretty flag with @daily.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@daily", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "Summary: Runs daily at 00:00" in output + + def test_pretty_flag_with_special_string_hourly(self): + """Test --pretty flag with @hourly.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@hourly", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "Summary: Runs every hour at minute 00" in output + + def test_pretty_flag_with_special_string_weekly(self): + """Test --pretty flag with @weekly.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@weekly", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "Summary: Runs every Sunday at 00:00" in output + + def test_pretty_flag_with_special_string_monthly(self): + """Test --pretty flag with @monthly.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@monthly", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "Summary: Runs on day 1 of every month at 00:00" in output + + def test_pretty_flag_with_special_string_yearly(self): + """Test --pretty flag with @yearly.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@yearly", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "Summary: Runs on January 1 at 00:00" in output + + def test_pretty_flag_with_special_string_reboot(self): + """Test --pretty flag with @reboot.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@reboot", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "Valid cron expression: @reboot" in output + assert "system startup" in output + + def test_pretty_flag_complex_expression(self): + """Test --pretty flag with complex expression.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["*/15 9-17 1,15 * MON-FRI", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "*/15" in output + assert "9-17" in output + assert "1,15" in output + assert "MON-FRI" in output + assert "Summary:" in output + + def test_pretty_flag_with_next_runs(self): + """Test --pretty flag combined with --next.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--pretty", "--next", "2"]) + + output = f.getvalue() + assert result == 0 + assert "Cron Expression Analysis" in output + assert "Summary:" in output + assert "Next 2 runs:" in output + + def test_pretty_flag_with_previous_runs(self): + """Test --pretty flag combined with --previous.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--pretty", "--previous", "2"]) + + output = f.getvalue() + assert result == 0 + assert "Cron Expression Analysis" in output + assert "Summary:" in output + assert "Previous 2 runs" in output + + def test_pretty_flag_with_timezone(self): + """Test --pretty flag combined with --timezone.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--pretty", "--timezone", "UTC"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone: UTC" in output + assert "Cron Expression Analysis" in output + + def test_pretty_flag_table_formatting(self): + """Test that table formatting is correct.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["30 14 1 6 3", "--pretty"]) + + output = f.getvalue() + assert result == 0 + + # Check table structure + assert "├" in output # Table separator + assert "┼" in output # Column separator + assert "─" in output # Horizontal line + + # Check all fields are present + assert "Minute" in output + assert "Hour" in output + assert "Day of Month" in output + assert "Month" in output + assert "Day of Week" in output + + # Check values + assert "30" in output + assert "14" in output + assert "1" in output + assert "6" in output + assert "3" in output + + def test_pretty_flag_field_descriptions(self): + """Test that field descriptions are accurate.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 */4 * * *", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "Every 4 hours" in output + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 15 * *", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "At day 15" in output or "day of month 15" in output + + def test_pretty_flag_summary_accuracy(self): + """Test that summaries are accurate for various patterns.""" + import io + import contextlib + + # Test weekly pattern + f = io.StringIO() + with contextlib.redirect_stdout(f): + main(["0 9 * * 1", "--pretty"]) + output = f.getvalue() + assert "Runs every Monday at 09:00" in output + + # Test monthly pattern + f = io.StringIO() + with contextlib.redirect_stdout(f): + main(["0 0 15 * *", "--pretty"]) + output = f.getvalue() + assert "Runs on day 15 of every month at 00:00" in output + + # Test yearly pattern + f = io.StringIO() + with contextlib.redirect_stdout(f): + main(["0 0 25 12 *", "--pretty"]) + output = f.getvalue() + assert "Runs on December 25 at 00:00" in output + + def test_pretty_flag_invalid_expression(self): + """Test --pretty flag with invalid expression.""" + import io + import contextlib + + f_err = io.StringIO() + with contextlib.redirect_stderr(f_err): + result = main(["invalid", "--pretty"]) + + error_output = f_err.getvalue() + assert result == 1 + assert "Invalid" in error_output + # Should not show pretty output for invalid expression \ No newline at end of file diff --git a/tests/test_pretty_printer.py b/tests/test_pretty_printer.py new file mode 100644 index 0000000..70835f8 --- /dev/null +++ b/tests/test_pretty_printer.py @@ -0,0 +1,477 @@ +"""Tests for pretty printer functionality.""" + +import sys +from pathlib import Path + +import pytest + +# Add src to path for testing +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from cronpal.field_parser import FieldParser +from cronpal.models import CronExpression +from cronpal.pretty_printer import PrettyPrinter + + +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 TestPrettyPrinterTable: + """Tests for table printing functionality.""" + + def test_print_table_basic(self): + """Test basic table printing.""" + expr = create_cron_expression("0 0 * * *") + printer = PrettyPrinter(expr) + + result = printer.print_table() + + assert "Cron Expression Analysis" in result + assert "Expression: 0 0 * * *" in result + assert "Field" in result + assert "Value" in result + assert "Description" in result + assert "Minute" in result + assert "Hour" in result + assert "Day of Month" in result + assert "Month" in result + assert "Day of Week" in result + + def test_print_table_with_ranges(self): + """Test table printing with ranges.""" + expr = create_cron_expression("0 9-17 * * *") + printer = PrettyPrinter(expr) + + result = printer.print_table() + + assert "9-17" in result + assert "From 9 to 17" in result + + def test_print_table_with_lists(self): + """Test table printing with lists.""" + expr = create_cron_expression("0,15,30,45 * * * *") + printer = PrettyPrinter(expr) + + result = printer.print_table() + + assert "0,15,30,45" in result + assert "At minutes 0, 15, 30, 45" in result + + def test_print_table_with_steps(self): + """Test table printing with step values.""" + expr = create_cron_expression("*/15 * * * *") + printer = PrettyPrinter(expr) + + result = printer.print_table() + + assert "*/15" in result + assert "Every 15 minutes" in result + + def test_print_table_with_names(self): + """Test table printing with month and day names.""" + expr = create_cron_expression("0 0 1 JAN MON") + printer = PrettyPrinter(expr) + + result = printer.print_table() + + assert "JAN" in result + assert "January" in result + assert "MON" in result + assert "Monday" in result + + +class TestPrettyPrinterSimple: + """Tests for simple printing functionality.""" + + def test_print_simple_basic(self): + """Test simple printing.""" + expr = create_cron_expression("0 0 * * *") + printer = PrettyPrinter(expr) + + result = printer.print_simple() + + assert "Cron Expression: 0 0 * * *" in result + assert "Minute:" in result + assert "Hour:" in result + assert "Day of Month:" in result + assert "Month:" in result + assert "Day of Week:" in result + + def test_print_simple_with_descriptions(self): + """Test simple printing includes descriptions.""" + expr = create_cron_expression("*/5 * * * *") + printer = PrettyPrinter(expr) + + result = printer.print_simple() + + assert "*/5" in result + assert "Every 5 minutes" in result + + def test_print_simple_formatting(self): + """Test simple printing formatting.""" + expr = create_cron_expression("30 2 15 6 3") + printer = PrettyPrinter(expr) + + result = printer.print_simple() + + lines = result.split("\n") + assert len(lines) >= 6 # Header + separator + 5 fields + assert "-" * 50 in result + + +class TestPrettyPrinterDetailed: + """Tests for detailed printing functionality.""" + + def test_print_detailed_basic(self): + """Test detailed printing.""" + expr = create_cron_expression("0 0 * * *") + printer = PrettyPrinter(expr) + + result = printer.print_detailed() + + assert "CRON EXPRESSION: 0 0 * * *" in result + assert "MINUTE" in result + assert "HOUR" in result + assert "DAY OF MONTH" in result + assert "MONTH" in result + assert "DAY OF WEEK" in result + assert "Raw Value:" in result + assert "Range:" in result + assert "Description:" in result + + def test_print_detailed_with_values(self): + """Test detailed printing with parsed values.""" + expr = create_cron_expression("0,30 9-17 * * MON-FRI") + printer = PrettyPrinter(expr) + + result = printer.print_detailed() + + assert "Values:" in result + assert "0, 30" in result + assert "9, 10, 11, 12, 13, 14, 15, 16, 17" in result + assert "1 (Monday), 2 (Tuesday)" in result + + def test_print_detailed_formatting(self): + """Test detailed printing formatting.""" + expr = create_cron_expression("0 0 1 1 *") + printer = PrettyPrinter(expr) + + result = printer.print_detailed() + + assert "═" * 80 in result + assert "▸" in result + assert "─" * 76 in result + + +class TestPrettyPrinterSummary: + """Tests for summary generation functionality.""" + + def test_get_summary_every_minute(self): + """Test summary for every minute.""" + expr = create_cron_expression("* * * * *") + printer = PrettyPrinter(expr) + + result = printer.get_summary() + + assert result == "Runs every minute" + + def test_get_summary_hourly(self): + """Test summary for hourly.""" + expr = create_cron_expression("0 * * * *") + printer = PrettyPrinter(expr) + + result = printer.get_summary() + + assert result == "Runs every hour at minute 00" + + def test_get_summary_daily(self): + """Test summary for daily.""" + expr = create_cron_expression("0 0 * * *") + printer = PrettyPrinter(expr) + + result = printer.get_summary() + + assert result == "Runs daily at 00:00" + + def test_get_summary_daily_specific_time(self): + """Test summary for daily at specific time.""" + expr = create_cron_expression("30 14 * * *") + printer = PrettyPrinter(expr) + + result = printer.get_summary() + + assert result == "Runs daily at 14:30" + + def test_get_summary_weekly(self): + """Test summary for weekly.""" + expr = create_cron_expression("0 0 * * 0") + printer = PrettyPrinter(expr) + + result = printer.get_summary() + + assert result == "Runs every Sunday at 00:00" + + def test_get_summary_weekly_specific(self): + """Test summary for weekly on specific day.""" + expr = create_cron_expression("30 9 * * 1") + printer = PrettyPrinter(expr) + + result = printer.get_summary() + + assert result == "Runs every Monday at 09:30" + + def test_get_summary_monthly(self): + """Test summary for monthly.""" + expr = create_cron_expression("0 0 1 * *") + printer = PrettyPrinter(expr) + + result = printer.get_summary() + + assert result == "Runs on day 1 of every month at 00:00" + + def test_get_summary_monthly_specific_day(self): + """Test summary for monthly on specific day.""" + expr = create_cron_expression("0 12 15 * *") + printer = PrettyPrinter(expr) + + result = printer.get_summary() + + assert result == "Runs on day 15 of every month at 12:00" + + def test_get_summary_yearly(self): + """Test summary for yearly.""" + expr = create_cron_expression("0 0 1 1 *") + printer = PrettyPrinter(expr) + + result = printer.get_summary() + + assert result == "Runs on January 1 at 00:00" + + def test_get_summary_yearly_specific(self): + """Test summary for yearly on specific date.""" + expr = create_cron_expression("30 23 31 12 *") + printer = PrettyPrinter(expr) + + result = printer.get_summary() + + assert result == "Runs on December 31 at 23:30" + + def test_get_summary_complex(self): + """Test summary for complex expression.""" + expr = create_cron_expression("*/15 9-17 * * MON-FRI") + printer = PrettyPrinter(expr) + + result = printer.get_summary() + + assert "Complex schedule" in result or "selected" in result + + def test_get_summary_with_lists(self): + """Test summary with list values.""" + expr = create_cron_expression("0 0 1,15 * *") + printer = PrettyPrinter(expr) + + result = printer.get_summary() + + assert "at 00:00" in result + assert "days 1, 15" in result + + +class TestPrettyPrinterHelpers: + """Tests for helper functions.""" + + def test_is_range(self): + """Test range detection.""" + expr = create_cron_expression("0 9-17 * * *") + printer = PrettyPrinter(expr) + + # 9-17 should be detected as a range + hour_values = expr.hour.parsed_values + assert printer._is_range(hour_values) is True + + # Non-continuous values should not be a range + assert printer._is_range({1, 3, 5}) is False + assert printer._is_range({1}) is False + + def test_is_step(self): + """Test step pattern detection.""" + expr = create_cron_expression("*/15 * * * *") + printer = PrettyPrinter(expr) + + # Every 15 minutes should be detected as a step + minute_values = expr.minute.parsed_values + assert printer._is_step(minute_values, 0, 59) is True + + # Non-step pattern + assert printer._is_step({1, 2, 4}, 0, 59) is False + + def test_get_step_value(self): + """Test getting step value.""" + expr = create_cron_expression("*/10 * * * *") + printer = PrettyPrinter(expr) + + minute_values = expr.minute.parsed_values + assert printer._get_step_value(minute_values) == 10 + + def test_get_month_name(self): + """Test month name conversion.""" + expr = create_cron_expression("0 0 1 6 *") + printer = PrettyPrinter(expr) + + assert printer._get_month_name(1) == "January" + assert printer._get_month_name(6) == "June" + assert printer._get_month_name(12) == "December" + assert printer._get_month_name(13) == "13" # Invalid month + + def test_get_weekday_name(self): + """Test weekday name conversion.""" + expr = create_cron_expression("0 0 * * 1") + printer = PrettyPrinter(expr) + + assert printer._get_weekday_name(0) == "Sunday" + assert printer._get_weekday_name(1) == "Monday" + assert printer._get_weekday_name(6) == "Saturday" + assert printer._get_weekday_name(7) == "7" # Invalid day + + def test_format_value_list_short(self): + """Test formatting short value lists.""" + expr = create_cron_expression("0,15,30,45 * * * *") + printer = PrettyPrinter(expr) + + from cronpal.models import FieldType + + result = printer._format_value_list({0, 15, 30, 45}, FieldType.MINUTE) + assert "0, 15, 30, 45" in result + + def test_format_value_list_long(self): + """Test formatting long value lists.""" + expr = create_cron_expression("* * * * *") + printer = PrettyPrinter(expr) + + from cronpal.models import FieldType + + # Minutes has 60 values (0-59) + minute_values = expr.minute.parsed_values + result = printer._format_value_list(minute_values, FieldType.MINUTE) + + assert "..." in result + assert "60 values" in result + + def test_format_value_list_months(self): + """Test formatting month value lists.""" + expr = create_cron_expression("0 0 1 1,6,12 *") + printer = PrettyPrinter(expr) + + from cronpal.models import FieldType + + month_values = expr.month.parsed_values + result = printer._format_value_list(month_values, FieldType.MONTH) + + assert "1 (January)" in result + assert "6 (June)" in result + assert "12 (December)" in result + + def test_format_value_list_weekdays(self): + """Test formatting weekday value lists.""" + expr = create_cron_expression("0 0 * * MON-FRI") + printer = PrettyPrinter(expr) + + from cronpal.models import FieldType + + weekday_values = expr.day_of_week.parsed_values + result = printer._format_value_list(weekday_values, FieldType.DAY_OF_WEEK) + + assert "1 (Monday)" in result + assert "5 (Friday)" in result + + +class TestPrettyPrinterCLI: + """Tests for pretty printer CLI integration.""" + + def test_cli_pretty_flag(self): + """Test --pretty flag in CLI.""" + import io + import contextlib + from cronpal.cli import main + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "Cron Expression Analysis" in output + assert "Summary:" in output + + def test_cli_pretty_with_verbose(self): + """Test --pretty with --verbose.""" + import io + import contextlib + from cronpal.cli import main + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["*/15 * * * *", "--pretty", "--verbose"]) + + output = f.getvalue() + assert result == 0 + assert "CRON EXPRESSION:" in output + assert "Raw Value:" in output + assert "Values:" in output + + def test_cli_pretty_with_special_string(self): + """Test --pretty with special string.""" + import io + import contextlib + from cronpal.cli import main + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@daily", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "Summary: Runs daily at 00:00" in output + + def test_cli_pretty_with_complex_expression(self): + """Test --pretty with complex expression.""" + import io + import contextlib + from cronpal.cli import main + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["*/5 9-17 1,15 * MON-FRI", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "*/5" in output + assert "9-17" in output + assert "1,15" in output + assert "MON-FRI" in output + + def test_cli_pretty_with_reboot(self): + """Test --pretty with @reboot.""" + import io + import contextlib + from cronpal.cli import main + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@reboot", "--pretty"]) + + output = f.getvalue() + assert result == 0 + assert "Valid cron expression: @reboot" in output + assert "runs at system startup" in output \ No newline at end of file