From 9ed84d17d3918ed485b0a85fcbd54d31cc563e13 Mon Sep 17 00:00:00 2001 From: Valentin Todorov Date: Mon, 22 Sep 2025 19:15:34 +0300 Subject: [PATCH] feat: add colored terminal output support --- pyproject.toml | 1 + src/cronpal/cli.py | 139 ++++++++++----- src/cronpal/color_utils.py | 280 ++++++++++++++++++++++++++++++ src/cronpal/parser.py | 7 + src/cronpal/pretty_printer.py | 79 ++++++--- tests/test_cli_color.py | 294 ++++++++++++++++++++++++++++++++ tests/test_color_utils.py | 284 ++++++++++++++++++++++++++++++ tests/test_integration_color.py | 115 +++++++++++++ 8 files changed, 1128 insertions(+), 71 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index edda397..bf26c1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ dependencies = [ "pytz>=2023.3", + "colorama>=0.4.6", ] [project.optional-dependencies] diff --git a/src/cronpal/cli.py b/src/cronpal/cli.py index e8f4731..cec23d6 100644 --- a/src/cronpal/cli.py +++ b/src/cronpal/cli.py @@ -4,6 +4,13 @@ import sys from datetime import datetime +from cronpal.color_utils import ( + ColorConfig, + format_error_message, + format_success_message, + get_color_config, + set_color_config, +) from cronpal.error_handler import ErrorHandler, suggest_fix from cronpal.exceptions import CronPalError from cronpal.field_parser import FieldParser @@ -28,19 +35,24 @@ def main(args=None): parser = create_parser() parsed_args = parser.parse_args(args) + # Initialize color configuration + use_colors = not getattr(parsed_args, 'no_color', False) + color_config = ColorConfig(use_colors=use_colors) + set_color_config(color_config) + # Handle version flag if parsed_args.version: from cronpal import __version__ - print(f"cronpal {__version__}") + print(color_config.info(f"cronpal {__version__}")) return 0 # Handle list timezones flag if parsed_args.list_timezones: - print("Available timezones:") + print(color_config.header("Available timezones:")) timezones = list_common_timezones() for tz in timezones: - print(f" {tz}") - print(f"\nTotal: {len(timezones)} timezones") + print(f" {color_config.value(tz)}") + print(f"\n{color_config.info(f'Total: {len(timezones)} timezones')}") return 0 # Handle cron expression @@ -57,7 +69,8 @@ def main(args=None): 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})") + tz_info = f"{parsed_args.timezone} ({tz_abbrev} {tz_offset})" + print(color_config.info(f"Using timezone: {tz_info}")) except ValueError as e: raise CronPalError(f"Invalid timezone: {e}") @@ -73,26 +86,33 @@ def main(args=None): 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.") + print(format_success_message( + f"Valid cron expression: {cron_expr.raw_expression}", + "This expression runs at system startup/reboot only." + )) else: - printer = PrettyPrinter(cron_expr) + printer = PrettyPrinter(cron_expr, use_colors=use_colors) print() print(printer.print_table()) print() - print(f"Summary: {printer.get_summary()}") + print(color_config.header("Summary: ") + + color_config.info(printer.get_summary())) if parsed_args.verbose: print() print(printer.print_detailed()) else: # Normal output - print(f"āœ” Valid cron expression: {cron_expr}") + print(format_success_message( + f"Valid cron expression: {cron_expr.raw_expression}" + )) if parsed_args.verbose: - print(f" Special string: {cron_expr.raw_expression}") + print(f" {color_config.field('Special string')}: " + f"{color_config.value(cron_expr.raw_expression)}") description = special_parser.get_description(cron_expr.raw_expression) - print(f" Description: {description}") + print(f" {color_config.field('Description')}: " + f"{color_config.info(description)}") # For @reboot, we don't have fields to show if cron_expr.raw_expression.lower() != "@reboot": @@ -114,22 +134,27 @@ def main(args=None): if parsed_args.pretty: # Pretty print mode - printer = PrettyPrinter(cron_expr) + printer = PrettyPrinter(cron_expr, use_colors=use_colors) print() print(printer.print_table()) print() - print(f"Summary: {printer.get_summary()}") + print(color_config.header("Summary: ") + + color_config.info(printer.get_summary())) if parsed_args.verbose: print() print(printer.print_detailed()) else: # Normal output - print(f"āœ” Valid cron expression: {cron_expr}") + print(format_success_message( + f"Valid cron expression: {cron_expr.raw_expression}" + )) if parsed_args.verbose: - print(f" Raw expression: {cron_expr.raw_expression}") - print(f" Validation: PASSED") + print(f" {color_config.field('Raw expression')}: " + f"{color_config.value(cron_expr.raw_expression)}") + print(f" {color_config.field('Validation')}: " + f"{color_config.success('PASSED')}") _print_verbose_fields(cron_expr) # Show next run times if requested @@ -144,18 +169,20 @@ def main(args=None): except CronPalError as e: # Use error handler for formatting - error_handler.print_error(e, parsed_args.expression) + print(format_error_message(str(e)), file=sys.stderr) # Suggest a fix if possible suggestion = suggest_fix(e, parsed_args.expression) if suggestion: - print(f" šŸ’” Suggestion: {suggestion}", file=sys.stderr) + color_config = get_color_config() + print(f" {color_config.warning('šŸ’” Suggestion:')} {suggestion}", + file=sys.stderr) return 1 except Exception as e: # Handle unexpected errors - error_handler.print_error(e, parsed_args.expression) + print(format_error_message(f"Unexpected error: {e}"), file=sys.stderr) return 2 # If no arguments provided, show help @@ -170,29 +197,36 @@ def _print_verbose_fields(cron_expr: CronExpression): Args: cron_expr: The CronExpression to print fields for. """ + config = get_color_config() + if cron_expr.minute: - print(f" Minute field: {cron_expr.minute.raw_value}") + print(f" {config.field('Minute field')}: " + f"{config.value(cron_expr.minute.raw_value)}") if cron_expr.minute.parsed_values: _print_field_values(" ", cron_expr.minute.parsed_values) if cron_expr.hour: - print(f" Hour field: {cron_expr.hour.raw_value}") + print(f" {config.field('Hour field')}: " + f"{config.value(cron_expr.hour.raw_value)}") if cron_expr.hour.parsed_values: _print_field_values(" ", cron_expr.hour.parsed_values) if cron_expr.day_of_month: - print(f" Day of month field: {cron_expr.day_of_month.raw_value}") + print(f" {config.field('Day of month field')}: " + f"{config.value(cron_expr.day_of_month.raw_value)}") if cron_expr.day_of_month.parsed_values: _print_field_values(" ", cron_expr.day_of_month.parsed_values) if cron_expr.month: - print(f" Month field: {cron_expr.month.raw_value}") + print(f" {config.field('Month field')}: " + f"{config.value(cron_expr.month.raw_value)}") if cron_expr.month.parsed_values: _print_field_values(" ", cron_expr.month.parsed_values) _print_month_names(" ", cron_expr.month.parsed_values) if cron_expr.day_of_week: - print(f" Day of week field: {cron_expr.day_of_week.raw_value}") + print(f" {config.field('Day of week field')}: " + f"{config.value(cron_expr.day_of_week.raw_value)}") if cron_expr.day_of_week.parsed_values: _print_field_values(" ", cron_expr.day_of_week.parsed_values) _print_day_names(" ", cron_expr.day_of_week.parsed_values) @@ -206,12 +240,16 @@ def _print_field_values(prefix: str, values: set): prefix: Prefix for each line. values: Set of values to print. """ + config = get_color_config() sorted_values = sorted(values) if len(sorted_values) <= 10: - print(f"{prefix}Values: {sorted_values}") + values_str = str(sorted_values) + print(f"{prefix}{config.field('Values')}: {config.value(values_str)}") else: - print(f"{prefix}Values: {sorted_values[:5]} ... {sorted_values[-5:]}") - print(f"{prefix}Total: {len(sorted_values)} values") + truncated = f"{sorted_values[:5]} ... {sorted_values[-5:]}" + print(f"{prefix}{config.field('Values')}: {config.value(truncated)}") + print(f"{prefix}{config.field('Total')}: " + f"{config.highlight(f'{len(sorted_values)} values')}") def _print_month_names(prefix: str, values: set): @@ -222,6 +260,7 @@ def _print_month_names(prefix: str, values: set): prefix: Prefix for each line. values: Set of month numbers to convert to names. """ + config = get_color_config() month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] @@ -229,7 +268,8 @@ def _print_month_names(prefix: str, values: set): names = [month_names[v - 1] for v in sorted_values if 1 <= v <= 12] if len(names) <= 10: - print(f"{prefix}Months: {', '.join(names)}") + print(f"{prefix}{config.field('Months')}: " + f"{config.info(', '.join(names))}") def _print_day_names(prefix: str, values: set): @@ -240,13 +280,15 @@ def _print_day_names(prefix: str, values: set): prefix: Prefix for each line. values: Set of day numbers to convert to names. """ + config = get_color_config() day_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] sorted_values = sorted(values) names = [day_names[v] for v in sorted_values if 0 <= v <= 6] if len(names) <= 7: - print(f"{prefix}Days: {', '.join(names)}") + print(f"{prefix}{config.field('Days')}: " + f"{config.info(', '.join(names))}") def _print_next_runs(cron_expr: CronExpression, count: int, timezone=None): @@ -258,21 +300,23 @@ def _print_next_runs(cron_expr: CronExpression, count: int, timezone=None): count: Number of next runs to show. timezone: Optional timezone for calculations. """ + config = get_color_config() + # Don't show next runs for @reboot if cron_expr.raw_expression.lower() == "@reboot": - print("\nNext runs: @reboot only runs at system startup") + print(f"\n{config.warning('Next runs: @reboot only runs at system startup')}") return # Make sure we have parsed fields if not cron_expr.is_valid(): - print("\nNext runs: Cannot calculate - incomplete expression") + print(f"\n{config.error('Next runs: Cannot calculate - incomplete expression')}") return try: scheduler = CronScheduler(cron_expr, timezone) next_runs = scheduler.get_next_runs(count) - print(f"\nNext {count} run{'s' if count != 1 else ''}:") + print(f"\n{config.header(f'Next {count} run')}{'s' if count != 1 else ''}:") for i, run_time in enumerate(next_runs, 1): # Format the datetime with timezone info if timezone: @@ -300,14 +344,16 @@ def _print_next_runs(cron_expr: CronExpression, count: int, timezone=None): relative = "" if relative: - print(f" {i}. {formatted} ({relative})") + print(f" {config.value(f'{i}.')} " + f"{config.highlight(formatted)} " + f"{config.separator('(')}({config.info(relative)}){config.separator(')')}") else: - print(f" {i}. {formatted}") + print(f" {config.value(f'{i}.')} {config.highlight(formatted)}") else: - print(f" {i}. {formatted}") + print(f" {config.value(f'{i}.')} {config.highlight(formatted)}") except Exception as e: - print(f"\nNext runs: Error calculating - {e}") + print(f"\n{config.error(f'Next runs: Error calculating - {e}')}") def _print_previous_runs(cron_expr: CronExpression, count: int, timezone=None): @@ -319,21 +365,24 @@ def _print_previous_runs(cron_expr: CronExpression, count: int, timezone=None): count: Number of previous runs to show. timezone: Optional timezone for calculations. """ + config = get_color_config() + # Don't show previous runs for @reboot if cron_expr.raw_expression.lower() == "@reboot": - print("\nPrevious runs: @reboot only runs at system startup") + print(f"\n{config.warning('Previous runs: @reboot only runs at system startup')}") return # Make sure we have parsed fields if not cron_expr.is_valid(): - print("\nPrevious runs: Cannot calculate - incomplete expression") + print(f"\n{config.error('Previous runs: Cannot calculate - incomplete expression')}") return try: 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):") + print(f"\n{config.header(f'Previous {count} run')}{'s' if count != 1 else ''} " + f"{config.info('(most recent first)')}:") for i, run_time in enumerate(previous_runs, 1): # Format the datetime with timezone info if timezone: @@ -361,14 +410,16 @@ def _print_previous_runs(cron_expr: CronExpression, count: int, timezone=None): relative = "" if relative: - print(f" {i}. {formatted} ({relative})") + print(f" {config.value(f'{i}.')} " + f"{config.info(formatted)} " + f"{config.separator('(')}({config.warning(relative)}){config.separator(')')}") else: - print(f" {i}. {formatted}") + print(f" {config.value(f'{i}.')} {config.info(formatted)}") else: - print(f" {i}. {formatted}") + print(f" {config.value(f'{i}.')} {config.info(formatted)}") except Exception as e: - print(f"\nPrevious runs: Error calculating - {e}") + print(f"\n{config.error(f'Previous runs: Error calculating - {e}')}") if __name__ == "__main__": diff --git a/src/cronpal/color_utils.py b/src/cronpal/color_utils.py index e69de29..17fbbae 100644 --- a/src/cronpal/color_utils.py +++ b/src/cronpal/color_utils.py @@ -0,0 +1,280 @@ +"""Color utilities for terminal output.""" + +import os +import sys +from enum import Enum +from typing import Optional + +try: + import colorama + from colorama import Fore, Back, Style + + colorama.init(autoreset=True) + COLORS_AVAILABLE = True +except ImportError: + COLORS_AVAILABLE = False + + + # Create dummy classes if colorama is not available + class Fore: + BLACK = RED = GREEN = YELLOW = BLUE = MAGENTA = CYAN = WHITE = "" + RESET = "" + LIGHTBLACK_EX = LIGHTRED_EX = LIGHTGREEN_EX = LIGHTYELLOW_EX = "" + LIGHTBLUE_EX = LIGHTMAGENTA_EX = LIGHTCYAN_EX = LIGHTWHITE_EX = "" + + + class Back: + BLACK = RED = GREEN = YELLOW = BLUE = MAGENTA = CYAN = WHITE = "" + RESET = "" + + + class Style: + DIM = NORMAL = BRIGHT = RESET_ALL = "" + + +class ColorScheme(Enum): + """Color schemes for different output types.""" + + SUCCESS = "success" + ERROR = "error" + WARNING = "warning" + INFO = "info" + HEADER = "header" + FIELD = "field" + VALUE = "value" + SEPARATOR = "separator" + HIGHLIGHT = "highlight" + + +class ColorConfig: + """Configuration for colors used in output.""" + + def __init__(self, use_colors: Optional[bool] = None): + """ + Initialize color configuration. + + Args: + use_colors: Whether to use colors. If None, auto-detect. + """ + if use_colors is None: + # Check NO_COLOR environment variable + if os.environ.get("NO_COLOR"): + self.use_colors = False + # Check if output is to a terminal + elif not sys.stdout.isatty(): + self.use_colors = False + else: + self.use_colors = COLORS_AVAILABLE + else: + self.use_colors = use_colors and COLORS_AVAILABLE + + # Define color mappings + self.colors = { + ColorScheme.SUCCESS: Fore.GREEN, + ColorScheme.ERROR: Fore.RED, + ColorScheme.WARNING: Fore.YELLOW, + ColorScheme.INFO: Fore.CYAN, + ColorScheme.HEADER: Fore.BLUE + Style.BRIGHT, + ColorScheme.FIELD: Fore.MAGENTA, + ColorScheme.VALUE: Fore.GREEN, + ColorScheme.SEPARATOR: Fore.LIGHTBLACK_EX, + ColorScheme.HIGHLIGHT: Fore.YELLOW + Style.BRIGHT, + } + + def get_color(self, scheme: ColorScheme) -> str: + """ + Get color code for a scheme. + + Args: + scheme: The color scheme to get. + + Returns: + Color code string or empty string if colors disabled. + """ + if not self.use_colors: + return "" + return self.colors.get(scheme, "") + + def colorize(self, text: str, scheme: ColorScheme) -> str: + """ + Colorize text with the specified scheme. + + Args: + text: Text to colorize. + scheme: Color scheme to apply. + + Returns: + Colored text or original text if colors disabled. + """ + if not self.use_colors: + return text + color = self.get_color(scheme) + if color: + return f"{color}{text}{Style.RESET_ALL}" + return text + + def success(self, text: str) -> str: + """Format success message.""" + return self.colorize(text, ColorScheme.SUCCESS) + + def error(self, text: str) -> str: + """Format error message.""" + return self.colorize(text, ColorScheme.ERROR) + + def warning(self, text: str) -> str: + """Format warning message.""" + return self.colorize(text, ColorScheme.WARNING) + + def info(self, text: str) -> str: + """Format info message.""" + return self.colorize(text, ColorScheme.INFO) + + def header(self, text: str) -> str: + """Format header text.""" + return self.colorize(text, ColorScheme.HEADER) + + def field(self, text: str) -> str: + """Format field name.""" + return self.colorize(text, ColorScheme.FIELD) + + def value(self, text: str) -> str: + """Format field value.""" + return self.colorize(text, ColorScheme.VALUE) + + def separator(self, text: str) -> str: + """Format separator.""" + return self.colorize(text, ColorScheme.SEPARATOR) + + def highlight(self, text: str) -> str: + """Format highlighted text.""" + return self.colorize(text, ColorScheme.HIGHLIGHT) + + +# Global color config instance +_color_config: Optional[ColorConfig] = None + + +def get_color_config() -> ColorConfig: + """ + Get the global color configuration. + + Returns: + The global ColorConfig instance. + """ + global _color_config + if _color_config is None: + _color_config = ColorConfig() + return _color_config + + +def set_color_config(config: ColorConfig) -> None: + """ + Set the global color configuration. + + Args: + config: The ColorConfig to use globally. + """ + global _color_config + _color_config = config + + +def reset_color_config() -> None: + """Reset the global color configuration.""" + global _color_config + _color_config = None + + +def format_cron_field(field_name: str, field_value: str, + description: Optional[str] = None) -> str: + """ + Format a cron field with colors. + + Args: + field_name: Name of the field. + field_value: Value of the field. + description: Optional description. + + Returns: + Formatted string with colors. + """ + config = get_color_config() + + result = f"{config.field(field_name)}: {config.value(field_value)}" + if description: + result += f" {config.separator('-')} {config.info(description)}" + + return result + + +def format_error_message(message: str, suggestion: Optional[str] = None) -> str: + """ + Format an error message with colors. + + Args: + message: Error message. + suggestion: Optional suggestion for fixing. + + Returns: + Formatted error message. + """ + config = get_color_config() + + result = config.error(f"āœ— {message}") + if suggestion: + result += f"\n {config.warning('šŸ’” Suggestion:')} {suggestion}" + + return result + + +def format_success_message(message: str, details: Optional[str] = None) -> str: + """ + Format a success message with colors. + + Args: + message: Success message. + details: Optional additional details. + + Returns: + Formatted success message. + """ + config = get_color_config() + + result = config.success(f"āœ“ {message}") + if details: + result += f"\n {config.info(details)}" + + return result + + +def format_table_border(char: str, width: int = 1) -> str: + """ + Format table border characters. + + Args: + char: Border character. + width: Number of times to repeat. + + Returns: + Formatted border string. + """ + config = get_color_config() + return config.separator(char * width) + + +def format_schedule_time(time_str: str, is_next: bool = True) -> str: + """ + Format a scheduled run time. + + Args: + time_str: Time string to format. + is_next: Whether this is a future time. + + Returns: + Formatted time string. + """ + config = get_color_config() + + if is_next: + return config.highlight(time_str) + else: + return config.info(time_str) \ No newline at end of file diff --git a/src/cronpal/parser.py b/src/cronpal/parser.py index 69c09ad..60ace66 100644 --- a/src/cronpal/parser.py +++ b/src/cronpal/parser.py @@ -19,6 +19,7 @@ def create_parser(): cronpal --help # Show this help message cronpal "0 0 * * *" --timezone "US/Eastern" # Use specific timezone cronpal "0 0 * * *" --pretty # Pretty print the expression + cronpal "0 0 * * *" --no-color # Disable colored output Cron Expression Format: ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ minute (0-59) @@ -84,4 +85,10 @@ def create_parser(): help="Pretty print the cron expression with formatted output" ) + parser.add_argument( + "--no-color", + action="store_true", + help="Disable colored output" + ) + return parser \ No newline at end of file diff --git a/src/cronpal/pretty_printer.py b/src/cronpal/pretty_printer.py index 2424308..a3361e1 100644 --- a/src/cronpal/pretty_printer.py +++ b/src/cronpal/pretty_printer.py @@ -2,20 +2,23 @@ from typing import List, Optional, Set +from cronpal.color_utils import ColorConfig, get_color_config from cronpal.models import CronExpression, CronField, FieldType class PrettyPrinter: """Pretty printer for cron expressions.""" - def __init__(self, expression: CronExpression): + def __init__(self, expression: CronExpression, use_colors: bool = True): """ Initialize the pretty printer. Args: expression: The CronExpression to pretty print. + use_colors: Whether to use colored output. """ self.expression = expression + self.color_config = ColorConfig(use_colors=use_colors) if use_colors else get_color_config() def print_table(self) -> str: """ @@ -25,15 +28,18 @@ def print_table(self) -> str: A string containing the formatted table. """ lines = [] + c = self.color_config # Shorthand # 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 + "┤") + lines.append(c.separator("ā”Œ" + "─" * 78 + "┐")) + lines.append(c.separator("│") + c.header(f" {'Cron Expression Analysis':^76} ") + c.separator("│")) + lines.append(c.separator("ā”œ" + "─" * 78 + "┤")) + lines.append(c.separator("│") + f" Expression: {c.value(self.expression.raw_expression):<63} " + c.separator("│")) + lines.append(c.separator("ā”œ" + "─" * 17 + "┬" + "─" * 15 + "┬" + "─" * 44 + "┤")) + lines.append(c.separator("│") + c.header(" Field ") + c.separator("│") + + c.header(" Value ") + c.separator("│") + + c.header(" Description ") + c.separator("│")) + lines.append(c.separator("ā”œ" + "─" * 17 + "┼" + "─" * 15 + "┼" + "─" * 44 + "┤")) # Fields if self.expression.minute: @@ -52,7 +58,7 @@ def print_table(self) -> str: lines.append(self._format_field_row("Day of Week", self.expression.day_of_week)) # Footer - lines.append("ā””" + "─" * 17 + "┓" + "─" * 15 + "┓" + "─" * 44 + "ā”˜") + lines.append(self.color_config.separator("ā””" + "─" * 17 + "┓" + "─" * 15 + "┓" + "─" * 44 + "ā”˜")) return "\n".join(lines) @@ -64,23 +70,35 @@ def print_simple(self) -> str: A string containing the simple formatted output. """ lines = [] - lines.append(f"Cron Expression: {self.expression.raw_expression}") - lines.append("-" * 50) + c = self.color_config + + lines.append(c.header("Cron Expression: ") + c.value(self.expression.raw_expression)) + lines.append(c.separator("-" * 50)) if self.expression.minute: - lines.append(f"Minute: {self.expression.minute.raw_value:10} {self._describe_field(self.expression.minute)}") + lines.append(c.field("Minute: ") + + c.value(f"{self.expression.minute.raw_value:10}") + " " + + c.info(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)}") + lines.append(c.field("Hour: ") + + c.value(f"{self.expression.hour.raw_value:10}") + " " + + c.info(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)}") + lines.append(c.field("Day of Month: ") + + c.value(f"{self.expression.day_of_month.raw_value:10}") + " " + + c.info(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)}") + lines.append(c.field("Month: ") + + c.value(f"{self.expression.month.raw_value:10}") + " " + + c.info(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)}") + lines.append(c.field("Day of Week: ") + + c.value(f"{self.expression.day_of_week.raw_value:10}") + " " + + c.info(self._describe_field(self.expression.day_of_week))) return "\n".join(lines) @@ -92,9 +110,11 @@ def print_detailed(self) -> str: A string containing the detailed output. """ lines = [] - lines.append("═" * 80) - lines.append(f" CRON EXPRESSION: {self.expression.raw_expression}") - lines.append("═" * 80) + c = self.color_config + + lines.append(c.separator("═" * 80)) + lines.append(c.header(f" CRON EXPRESSION: {self.expression.raw_expression}")) + lines.append(c.separator("═" * 80)) if self.expression.minute: lines.extend(self._format_detailed_field("MINUTE", self.expression.minute)) @@ -111,7 +131,7 @@ def print_detailed(self) -> str: if self.expression.day_of_week: lines.extend(self._format_detailed_field("DAY OF WEEK", self.expression.day_of_week)) - lines.append("═" * 80) + lines.append(c.separator("═" * 80)) return "\n".join(lines) @@ -172,26 +192,31 @@ def get_summary(self) -> str: def _format_field_row(self, name: str, field: CronField) -> str: """Format a single field row for the table.""" + c = self.color_config 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} │" + return (c.separator("│") + " " + c.field(f"{name:<15}") + " " + c.separator("│") + " " + + c.value(f"{field.raw_value:<13}") + " " + c.separator("│") + " " + + c.info(f"{description:<42}") + " " + c.separator("│")) def _format_detailed_field(self, name: str, field: CronField) -> List[str]: """Format detailed field information.""" lines = [] + c = self.color_config + 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)}") + lines.append(c.header(f"ā–ø {name}")) + lines.append(" " + c.separator("─" * 76)) + lines.append(f" {c.field('Raw Value:'):12} {c.value(field.raw_value)}") + lines.append(f" {c.field('Range:'):12} {c.info(f'{field.field_range.min_value}-{field.field_range.max_value}')}") + lines.append(f" {c.field('Description:'):12} {c.info(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}") + lines.append(f" {c.field('Values:'):12} {c.highlight(values_str)}") return lines diff --git a/tests/test_cli_color.py b/tests/test_cli_color.py index e69de29..0c66f20 100644 --- a/tests/test_cli_color.py +++ b/tests/test_cli_color.py @@ -0,0 +1,294 @@ +"""Tests for CLI color output 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 +from cronpal.color_utils import ColorConfig, set_color_config + + +class TestCLIColorOutput: + """Tests for CLI colored output functionality.""" + + def setup_method(self): + """Setup for each test.""" + # Ensure colors are disabled for consistent testing + set_color_config(ColorConfig(use_colors=False)) + + def test_no_color_flag(self): + """Test --no-color flag disables colors.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--no-color"]) + + output = f.getvalue() + assert result == 0 + # Should not contain ANSI escape codes + assert "\033[" not in output + assert "āœ“ Valid cron expression" in output + + def test_colored_success_message(self): + """Test colored success message.""" + import io + import contextlib + + # Enable colors for this test + set_color_config(ColorConfig(use_colors=True)) + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *"]) + + output = f.getvalue() + assert result == 0 + assert "āœ“ Valid cron expression" in output + + def test_colored_error_message(self): + """Test colored error message.""" + import io + import contextlib + + f_err = io.StringIO() + with contextlib.redirect_stderr(f_err): + result = main(["invalid", "--no-color"]) + + error_output = f_err.getvalue() + assert result == 1 + assert "āœ—" in error_output + # Should not contain ANSI codes with --no-color + assert "\033[" not in error_output + + def test_colored_verbose_output(self): + """Test colored verbose output.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--verbose", "--no-color"]) + + output = f.getvalue() + assert result == 0 + assert "Minute field:" in output + assert "Hour field:" in output + # No ANSI codes + assert "\033[" not in output + + def test_colored_pretty_print(self): + """Test colored pretty print output.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--pretty", "--no-color"]) + + output = f.getvalue() + assert result == 0 + assert "Cron Expression Analysis" in output + assert "Summary:" in output + # No ANSI codes + assert "\033[" not in output + + def test_colored_next_runs(self): + """Test colored next runs output.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--next", "3", "--no-color"]) + + output = f.getvalue() + assert result == 0 + assert "Next 3 runs:" in output + # No ANSI codes + assert "\033[" not in output + + def test_colored_previous_runs(self): + """Test colored previous runs output.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--previous", "3", "--no-color"]) + + output = f.getvalue() + assert result == 0 + assert "Previous 3 runs" in output + # No ANSI codes + assert "\033[" not in output + + def test_colored_timezone_output(self): + """Test colored timezone output.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * *", "--timezone", "UTC", "--no-color"]) + + output = f.getvalue() + assert result == 0 + assert "Using timezone:" in output + assert "UTC" in output + # No ANSI codes + assert "\033[" not in output + + def test_colored_list_timezones(self): + """Test colored list timezones output.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["--list-timezones", "--no-color"]) + + output = f.getvalue() + assert result == 0 + assert "Available timezones:" in output + assert "Total:" in output + # No ANSI codes + assert "\033[" not in output + + def test_colored_version_output(self): + """Test colored version output.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["--version", "--no-color"]) + + output = f.getvalue() + assert result == 0 + assert "cronpal" in output + # No ANSI codes + assert "\033[" not in output + + def test_colored_special_string(self): + """Test colored output for special string.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@daily", "--no-color"]) + + output = f.getvalue() + assert result == 0 + assert "āœ“ Valid cron expression: @daily" in output + # No ANSI codes + assert "\033[" not in output + + def test_colored_special_string_verbose(self): + """Test colored verbose output for special string.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["@hourly", "--verbose", "--no-color"]) + + output = f.getvalue() + assert result == 0 + assert "Special string:" in output + assert "Description:" in output + # No ANSI codes + assert "\033[" not in output + + def test_pretty_print_table_colors(self): + """Test that pretty print table uses colors correctly.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["*/15 9-17 * * MON-FRI", "--pretty", "--no-color"]) + + output = f.getvalue() + assert result == 0 + # Check table elements are present + assert "ā”Œ" in output + assert "│" in output + assert "ā””" in output + assert "Field" in output + assert "Value" in output + assert "Description" in output + # No ANSI codes + assert "\033[" not in output + + def test_error_suggestion_colors(self): + """Test that error suggestions use colors correctly.""" + import io + import contextlib + + f_err = io.StringIO() + with contextlib.redirect_stderr(f_err): + result = main(["0 0 *", "--no-color"]) + + error_output = f_err.getvalue() + assert result == 1 + assert "šŸ’” Suggestion:" in error_output + # No ANSI codes + assert "\033[" not in error_output + + def test_month_names_colored_output(self): + """Test month names in colored output.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 1 JAN,JUN,DEC *", "--verbose", "--no-color"]) + + output = f.getvalue() + assert result == 0 + assert "Month field: JAN,JUN,DEC" in output + assert "Months:" in output + assert "Jan" in output or "Jun" in output or "Dec" in output + # No ANSI codes + assert "\033[" not in output + + def test_day_names_colored_output(self): + """Test day names in colored output.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["0 0 * * MON-FRI", "--verbose", "--no-color"]) + + output = f.getvalue() + assert result == 0 + assert "Day of week field: MON-FRI" in output + assert "Days:" in output + # No ANSI codes + assert "\033[" not in output + + def test_relative_time_colored_output(self): + """Test relative time in colored output.""" + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f): + result = main(["* * * * *", "--next", "1", "--no-color"]) + + output = f.getvalue() + assert result == 0 + assert "Next 1 run:" in output + # Should have relative time like "in X minutes" + assert "in" in output.lower() or "minute" in output.lower() + # No ANSI codes + assert "\033[" not in output \ No newline at end of file diff --git a/tests/test_color_utils.py b/tests/test_color_utils.py index e69de29..c761474 100644 --- a/tests/test_color_utils.py +++ b/tests/test_color_utils.py @@ -0,0 +1,284 @@ +"""Tests for color utilities.""" + +import os +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.color_utils import ( + ColorConfig, + ColorScheme, + format_cron_field, + format_error_message, + format_schedule_time, + format_success_message, + format_table_border, + get_color_config, + reset_color_config, + set_color_config, +) + + +class TestColorConfig: + """Tests for ColorConfig class.""" + + def test_init_default(self): + """Test default initialization.""" + config = ColorConfig() + # Should auto-detect based on terminal + assert config.use_colors in [True, False] + + def test_init_with_colors(self): + """Test initialization with colors enabled.""" + config = ColorConfig(use_colors=True) + # Will be True if colorama is available + assert config.use_colors in [True, False] + + def test_init_without_colors(self): + """Test initialization with colors disabled.""" + config = ColorConfig(use_colors=False) + assert config.use_colors is False + + def test_no_color_env_variable(self, monkeypatch): + """Test NO_COLOR environment variable.""" + monkeypatch.setenv("NO_COLOR", "1") + config = ColorConfig() + assert config.use_colors is False + + def test_get_color_with_colors_disabled(self): + """Test getting color when colors are disabled.""" + config = ColorConfig(use_colors=False) + color = config.get_color(ColorScheme.SUCCESS) + assert color == "" + + def test_colorize_with_colors_disabled(self): + """Test colorizing text when colors are disabled.""" + config = ColorConfig(use_colors=False) + result = config.colorize("test", ColorScheme.SUCCESS) + assert result == "test" + + def test_success_method(self): + """Test success formatting method.""" + config = ColorConfig(use_colors=False) + result = config.success("Success!") + assert result == "Success!" + + def test_error_method(self): + """Test error formatting method.""" + config = ColorConfig(use_colors=False) + result = config.error("Error!") + assert result == "Error!" + + def test_warning_method(self): + """Test warning formatting method.""" + config = ColorConfig(use_colors=False) + result = config.warning("Warning!") + assert result == "Warning!" + + def test_info_method(self): + """Test info formatting method.""" + config = ColorConfig(use_colors=False) + result = config.info("Info") + assert result == "Info" + + def test_header_method(self): + """Test header formatting method.""" + config = ColorConfig(use_colors=False) + result = config.header("Header") + assert result == "Header" + + def test_field_method(self): + """Test field formatting method.""" + config = ColorConfig(use_colors=False) + result = config.field("Field") + assert result == "Field" + + def test_value_method(self): + """Test value formatting method.""" + config = ColorConfig(use_colors=False) + result = config.value("Value") + assert result == "Value" + + def test_separator_method(self): + """Test separator formatting method.""" + config = ColorConfig(use_colors=False) + result = config.separator("-") + assert result == "-" + + def test_highlight_method(self): + """Test highlight formatting method.""" + config = ColorConfig(use_colors=False) + result = config.highlight("Important") + assert result == "Important" + + +class TestGlobalColorConfig: + """Tests for global color config management.""" + + def teardown_method(self): + """Reset global config after each test.""" + reset_color_config() + + def test_get_color_config_creates_default(self): + """Test that get_color_config creates a default config.""" + config = get_color_config() + assert config is not None + assert isinstance(config, ColorConfig) + + def test_get_color_config_returns_same_instance(self): + """Test that get_color_config returns the same instance.""" + config1 = get_color_config() + config2 = get_color_config() + assert config1 is config2 + + def test_set_color_config(self): + """Test setting a custom color config.""" + custom = ColorConfig(use_colors=False) + set_color_config(custom) + + retrieved = get_color_config() + assert retrieved is custom + assert retrieved.use_colors is False + + def test_reset_color_config(self): + """Test resetting the global config.""" + custom = ColorConfig(use_colors=False) + set_color_config(custom) + + reset_color_config() + + # Should create a new default config + new_config = get_color_config() + assert new_config is not custom + + +class TestFormatFunctions: + """Tests for formatting utility functions.""" + + def test_format_cron_field_basic(self): + """Test basic cron field formatting.""" + reset_color_config() + set_color_config(ColorConfig(use_colors=False)) + + result = format_cron_field("Minute", "*/15") + assert "Minute: */15" in result + + def test_format_cron_field_with_description(self): + """Test cron field formatting with description.""" + reset_color_config() + set_color_config(ColorConfig(use_colors=False)) + + result = format_cron_field("Hour", "9-17", "Business hours") + assert "Hour: 9-17" in result + assert "Business hours" in result + + def test_format_error_message_basic(self): + """Test basic error message formatting.""" + reset_color_config() + set_color_config(ColorConfig(use_colors=False)) + + result = format_error_message("Invalid expression") + assert "āœ— Invalid expression" in result + + def test_format_error_message_with_suggestion(self): + """Test error message with suggestion.""" + reset_color_config() + set_color_config(ColorConfig(use_colors=False)) + + result = format_error_message("Invalid", "Try adding more fields") + assert "āœ— Invalid" in result + assert "šŸ’” Suggestion:" in result + assert "Try adding more fields" in result + + def test_format_success_message_basic(self): + """Test basic success message formatting.""" + reset_color_config() + set_color_config(ColorConfig(use_colors=False)) + + result = format_success_message("Valid expression") + assert "āœ“ Valid expression" in result + + def test_format_success_message_with_details(self): + """Test success message with details.""" + reset_color_config() + set_color_config(ColorConfig(use_colors=False)) + + result = format_success_message("Valid", "Runs daily") + assert "āœ“ Valid" in result + assert "Runs daily" in result + + def test_format_table_border(self): + """Test table border formatting.""" + reset_color_config() + set_color_config(ColorConfig(use_colors=False)) + + result = format_table_border("─", 10) + assert result == "─" * 10 + + def test_format_schedule_time_next(self): + """Test formatting next scheduled time.""" + reset_color_config() + set_color_config(ColorConfig(use_colors=False)) + + result = format_schedule_time("2024-01-15 10:00:00", is_next=True) + assert result == "2024-01-15 10:00:00" + + def test_format_schedule_time_previous(self): + """Test formatting previous scheduled time.""" + reset_color_config() + set_color_config(ColorConfig(use_colors=False)) + + result = format_schedule_time("2024-01-15 10:00:00", is_next=False) + assert result == "2024-01-15 10:00:00" + + +class TestColorSchemeEnum: + """Tests for ColorScheme enum.""" + + def test_color_scheme_values(self): + """Test ColorScheme enum values.""" + assert ColorScheme.SUCCESS.value == "success" + assert ColorScheme.ERROR.value == "error" + assert ColorScheme.WARNING.value == "warning" + assert ColorScheme.INFO.value == "info" + assert ColorScheme.HEADER.value == "header" + assert ColorScheme.FIELD.value == "field" + assert ColorScheme.VALUE.value == "value" + assert ColorScheme.SEPARATOR.value == "separator" + assert ColorScheme.HIGHLIGHT.value == "highlight" + + +class TestColorOutput: + """Tests for actual color output (when available).""" + + def test_colors_when_available(self): + """Test color output when colorama is available.""" + try: + import colorama + # If colorama is available, test with colors enabled + config = ColorConfig(use_colors=True) + + if config.use_colors: + # Colors are available + colored_text = config.success("Success") + # Should contain ANSI codes or actual text + assert "Success" in colored_text + + error_text = config.error("Error") + assert "Error" in error_text + except ImportError: + # colorama not available, skip this test + pass + + def test_no_colors_in_non_tty(self, monkeypatch): + """Test that colors are disabled for non-TTY output.""" + # Mock isatty to return False + monkeypatch.setattr(sys.stdout, 'isatty', lambda: False) + + config = ColorConfig() + # Should be disabled for non-TTY + assert config.use_colors is False \ No newline at end of file diff --git a/tests/test_integration_color.py b/tests/test_integration_color.py index e69de29..df47a50 100644 --- a/tests/test_integration_color.py +++ b/tests/test_integration_color.py @@ -0,0 +1,115 @@ +"""Integration tests for colored output.""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +# Get the project root +PROJECT_ROOT = Path(__file__).parent.parent + + +def test_color_output_disabled_with_flag(): + """Test that --no-color flag disables color output.""" + result = subprocess.run( + [sys.executable, "-m", "cronpal.cli", "0 0 * * *", "--no-color"], + capture_output=True, + text=True, + cwd=PROJECT_ROOT / "src" + ) + + assert result.returncode == 0 + # Check that ANSI codes are not present + assert "\033[" not in result.stdout + assert "Valid cron expression" in result.stdout + + +def test_color_output_in_pretty_mode(): + """Test colored output in pretty mode.""" + result = subprocess.run( + [sys.executable, "-m", "cronpal.cli", "*/15 * * * *", "--pretty", "--no-color"], + capture_output=True, + text=True, + cwd=PROJECT_ROOT / "src" + ) + + assert result.returncode == 0 + # Check table elements + assert "ā”Œ" in result.stdout + assert "│" in result.stdout + assert "Cron Expression Analysis" in result.stdout + assert "Every 15 minutes" in result.stdout + # No ANSI codes + assert "\033[" not in result.stdout + + +def test_color_output_error_messages(): + """Test colored output for error messages.""" + result = subprocess.run( + [sys.executable, "-m", "cronpal.cli", "invalid", "--no-color"], + capture_output=True, + text=True, + cwd=PROJECT_ROOT / "src" + ) + + assert result.returncode == 1 + # Error should be in stderr + assert "āœ—" in result.stderr + assert "Invalid" in result.stderr + # No ANSI codes + assert "\033[" not in result.stderr + + +def test_color_output_verbose_mode(): + """Test colored output in verbose mode.""" + result = subprocess.run( + [sys.executable, "-m", "cronpal.cli", "@daily", "--verbose", "--no-color"], + capture_output=True, + text=True, + cwd=PROJECT_ROOT / "src" + ) + + assert result.returncode == 0 + assert "Valid cron expression" in result.stdout + assert "Special string:" in result.stdout + assert "Description:" in result.stdout + # No ANSI codes + assert "\033[" not in result.stdout + + +def test_color_output_next_runs(): + """Test colored output for next runs.""" + result = subprocess.run( + [sys.executable, "-m", "cronpal.cli", "0 12 * * *", "--next", "2", "--no-color"], + capture_output=True, + text=True, + cwd=PROJECT_ROOT / "src" + ) + + assert result.returncode == 0 + assert "Next 2 runs:" in result.stdout + assert "1." in result.stdout + assert "2." in result.stdout + # No ANSI codes + assert "\033[" not in result.stdout + + +def test_no_color_environment_variable(): + """Test that NO_COLOR environment variable disables colors.""" + import os + env = os.environ.copy() + env["NO_COLOR"] = "1" + + result = subprocess.run( + [sys.executable, "-m", "cronpal.cli", "0 0 * * *"], + capture_output=True, + text=True, + cwd=PROJECT_ROOT / "src", + env=env + ) + + assert result.returncode == 0 + # Should not have ANSI codes when NO_COLOR is set + assert "\033[" not in result.stdout + assert "Valid cron expression" in result.stdout \ No newline at end of file