diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 66e7b8c..bb226df 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,6 +49,7 @@ repos: - requests - tomli; python_version<"3.11" - setuptools + - colorama - repo: https://github.com/gitleaks/gitleaks rev: v8.21.2 diff --git a/command_line_assistant/rendering/__init__.py b/command_line_assistant/rendering/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/command_line_assistant/rendering/decorators/__init__.py b/command_line_assistant/rendering/decorators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/command_line_assistant/rendering/decorators/base.py b/command_line_assistant/rendering/decorators/base.py new file mode 100644 index 0000000..fdb653e --- /dev/null +++ b/command_line_assistant/rendering/decorators/base.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + + +class RenderDecorator(ABC): + """Abstract base class for render decorators""" + + @abstractmethod + def decorate(self, text: str) -> str: + pass diff --git a/command_line_assistant/rendering/decorators/colors.py b/command_line_assistant/rendering/decorators/colors.py new file mode 100644 index 0000000..5d12445 --- /dev/null +++ b/command_line_assistant/rendering/decorators/colors.py @@ -0,0 +1,105 @@ +import os +from typing import Optional + +from colorama import Back, Fore, Style + +from command_line_assistant.rendering.decorators.base import RenderDecorator + + +class ColorDecorator(RenderDecorator): + """Decorator for adding foreground and background colors to text using colorama""" + + # Color name mappings for better IDE support and type checking + FOREGROUND_COLORS = { + "black": Fore.BLACK, + "red": Fore.RED, + "green": Fore.GREEN, + "yellow": Fore.YELLOW, + "blue": Fore.BLUE, + "magenta": Fore.MAGENTA, + "cyan": Fore.CYAN, + "white": Fore.WHITE, + "reset": Fore.RESET, + # Light variants + "lightblack": Fore.LIGHTBLACK_EX, + "lightred": Fore.LIGHTRED_EX, + "lightgreen": Fore.LIGHTGREEN_EX, + "lightyellow": Fore.LIGHTYELLOW_EX, + "lightblue": Fore.LIGHTBLUE_EX, + "lightmagenta": Fore.LIGHTMAGENTA_EX, + "lightcyan": Fore.LIGHTCYAN_EX, + "lightwhite": Fore.LIGHTWHITE_EX, + } + + BACKGROUND_COLORS = { + "black": Back.BLACK, + "red": Back.RED, + "green": Back.GREEN, + "yellow": Back.YELLOW, + "blue": Back.BLUE, + "magenta": Back.MAGENTA, + "cyan": Back.CYAN, + "white": Back.WHITE, + "reset": Back.RESET, + # Light variants + "lightblack": Back.LIGHTBLACK_EX, + "lightred": Back.LIGHTRED_EX, + "lightgreen": Back.LIGHTGREEN_EX, + "lightyellow": Back.LIGHTYELLOW_EX, + "lightblue": Back.LIGHTBLUE_EX, + "lightmagenta": Back.LIGHTMAGENTA_EX, + "lightcyan": Back.LIGHTCYAN_EX, + "lightwhite": Back.LIGHTWHITE_EX, + } + + def __init__( + self, + foreground: str = "white", + background: Optional[str] = None, + ) -> None: + """ + Initialize the color decorator with the specified colors and style. + + Args: + foreground: Foreground color name (default: "white") + background: Optional background color name + style: Optional style name ("dim", "normal", "bright") + """ + self.foreground = self._get_foreground_color(foreground) + self.background = self._get_background_color(background) if background else "" + + def _get_foreground_color(self, color: str) -> str: + """Get the colorama foreground color code.""" + color = color.lower() + if color not in self.FOREGROUND_COLORS: + raise ValueError( + f"Invalid foreground color. Choose from: {', '.join(self.FOREGROUND_COLORS.keys())}" + ) + return self.FOREGROUND_COLORS[color] + + def _get_background_color(self, color: str) -> str: + """Get the colorama background color code.""" + color = color.lower() + if color not in self.BACKGROUND_COLORS: + raise ValueError( + f"Invalid background color. Choose from: {', '.join(self.BACKGROUND_COLORS.keys())}" + ) + return self.BACKGROUND_COLORS[color] + + def decorate(self, text: str) -> str: + """Apply the color formatting to the text.""" + formatted_text = f"{self.background}{self.foreground}{text}{Style.RESET_ALL}" + return formatted_text + + +def should_disable_color_output(): + """ + Return whether NO_COLOR exists in environment parameter and is true. + + See https://no-color.org/ + """ + if "NO_COLOR" in os.environ: + no_color = os.environ["NO_COLOR"] + return no_color is not None and no_color != "0" and no_color.lower() != "false" + + return False diff --git a/command_line_assistant/rendering/decorators/style.py b/command_line_assistant/rendering/decorators/style.py new file mode 100644 index 0000000..96ed1de --- /dev/null +++ b/command_line_assistant/rendering/decorators/style.py @@ -0,0 +1,41 @@ +from typing import Optional + +from colorama import Style + +from command_line_assistant.rendering.decorators.base import RenderDecorator + + +class StyleDecorator(RenderDecorator): + """Decorator for adding text styles using colorama""" + + STYLES = { + "dim": Style.DIM, + "normal": Style.NORMAL, + "bright": Style.BRIGHT, + "reset": Style.RESET_ALL, + } + + def __init__(self, style: Optional[str] = None) -> None: + """ + Initialize the style decorator with the specified styles. + + Args: + style: Name of a style to be applied ("dim", "normal", "bright") + """ + self.style = self._get_style(style) if style else None + + def _get_style(self, style: str) -> str: + """Get the colorama style code.""" + style = style.lower() + if style not in self.STYLES: + raise ValueError( + f"Invalid style. Choose from: {', '.join(self.STYLES.keys())}" + ) + return self.STYLES[style] + + def decorate(self, text: str) -> str: + """Apply the style formatting to the text.""" + if self.style: + return f"{self.style}{text}{Style.RESET_ALL}" + + return text diff --git a/command_line_assistant/rendering/decorators/text.py b/command_line_assistant/rendering/decorators/text.py new file mode 100644 index 0000000..01a6f02 --- /dev/null +++ b/command_line_assistant/rendering/decorators/text.py @@ -0,0 +1,42 @@ +import shutil +import textwrap +from typing import Optional, Union + +from command_line_assistant.rendering.decorators.base import RenderDecorator + + +class EmojiDecorator(RenderDecorator): + def __init__(self, emoji: Union[str, int]) -> None: + self._emoji = self._normalize_emoji(emoji) + + def _normalize_emoji(self, emoji: Union[str, int]) -> str: + if isinstance(emoji, int): + return chr(emoji) + + if isinstance(emoji, str): + # If already an emoji character + if len(emoji) <= 2 and ord(emoji[0]) > 127: + return emoji + + # Convert code point to emoji + code = emoji.upper().replace("U+", "").replace("0X", "") + return chr(int(code, 16)) + + raise TypeError(f"Emoji must be string or int, not {type(emoji)}") + + def decorate(self, text: str) -> str: + return f"{self._emoji} {text}" + + +class TextWrapDecorator(RenderDecorator): + def __init__(self, width: Optional[int] = None, indent: str = "") -> None: + self._width = width or shutil.get_terminal_size().columns + self._indent = indent + + def decorate(self, text: str) -> str: + return textwrap.fill( + text, + width=self._width, + initial_indent=self._indent, + subsequent_indent=self._indent, + ) diff --git a/command_line_assistant/rendering/render.py b/command_line_assistant/rendering/render.py new file mode 100644 index 0000000..973c61b --- /dev/null +++ b/command_line_assistant/rendering/render.py @@ -0,0 +1,22 @@ +import shutil + +from command_line_assistant.rendering.decorators.base import RenderDecorator + + +class TextRenderer: + def __init__(self) -> None: + # Fetch the current terminal size on initialization + self.terminal_width = shutil.get_terminal_size().columns + self._decorators: dict[type, RenderDecorator] = {} + + def update(self, decorator: RenderDecorator) -> None: + """Update or add a decorator of the same type.""" + self._decorators[type(decorator)] = decorator + + def render(self, text: str): + decorated_text = text + # Apply all decorators except Spinner + for decorator in self._decorators.values(): + decorated_text = decorator.decorate(decorated_text) + + print(decorated_text) diff --git a/command_line_assistant/rendering/spinner.py b/command_line_assistant/rendering/spinner.py new file mode 100644 index 0000000..2905f7a --- /dev/null +++ b/command_line_assistant/rendering/spinner.py @@ -0,0 +1,49 @@ +import itertools +import sys +import threading +import time +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Generator, Iterator + + +@dataclass +class Frames: + default: Iterator[str] = itertools.cycle(["-", "\\", "|", "/"]) + braille: Iterator[str] = itertools.cycle(["⠋", "⠙", "⠸", "⠴", "⠦", "⠇"]) + circular: Iterator[str] = itertools.cycle(["◐", "◓", "◑", "◒"]) + dots: Iterator[str] = itertools.cycle([". ", ".. ", "...", " ..", " .", " "]) + arrows: Iterator[str] = itertools.cycle(["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"]) + moving: Iterator[str] = itertools.cycle( + ["[ ]", "[= ]", "[== ]", "[===]", "[ ==]", "[ =]", "[ ]"] + ) + + +@contextmanager +def ascii_spinner( + message: str, + clear_message: bool = False, + frames: Iterator[str] = Frames.default, + delay: float = 0.1, +) -> Generator: + done = threading.Event() + + def animation() -> None: + while not done.is_set(): + sys.stdout.write(f"\r{next(frames)} {message}") # Write the current frame + sys.stdout.flush() + time.sleep(delay) # Delay between frames + + spinner_thread = threading.Thread(target=animation) + spinner_thread.start() + + try: + yield + finally: + done.set() # Signal the spinner to stop + spinner_thread.join() # Wait for the spinner thread to finish + sys.stdout.write("\r\n") + if clear_message: + # Clear the message by overwriting it with spaces and resetting the cursor + sys.stdout.write("\r" + " " * (len(message) + 2) + "\r") # Clear the line + sys.stdout.flush() diff --git a/pdm.lock b/pdm.lock index 834142e..887f913 100644 --- a/pdm.lock +++ b/pdm.lock @@ -134,7 +134,7 @@ name = "colorama" version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." -groups = ["dev"] +groups = ["default", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, diff --git a/tests/commands/test_commands.py b/tests/commands/test_commands.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/commands/test_query.py b/tests/commands/test_query.py index e611216..ca8c66d 100644 --- a/tests/commands/test_query.py +++ b/tests/commands/test_query.py @@ -91,7 +91,7 @@ def test_query_command_config_validation(mock_config): @patch("command_line_assistant.commands.query.handle_query") def test_query_command_with_special_characters(mock_handle_query, mock_config): """Test QueryCommand with special characters in query""" - special_query = "test\nquery\twith\rspecial\characters" + special_query = r"test\nquery\twith\rspecial\characters" command = QueryCommand(special_query, mock_config) command.run() mock_handle_query.assert_called_once_with(special_query, mock_config) diff --git a/tests/rendering/__init__.py b/tests/rendering/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/rendering/decorators/__init__.py b/tests/rendering/decorators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/rendering/decorators/test_colors.py b/tests/rendering/decorators/test_colors.py new file mode 100644 index 0000000..7ba7fa9 --- /dev/null +++ b/tests/rendering/decorators/test_colors.py @@ -0,0 +1,32 @@ +import pytest + +from command_line_assistant.rendering.decorators.colors import ( + ColorDecorator, +) + + +def test_color_decorator_basic(): + decorator = ColorDecorator(foreground="red") + text = "Test text" + result = decorator.decorate(text) + assert result.startswith("\x1b[31m") # Red color code + assert result.endswith("\x1b[0m") # Reset code + assert "Test text" in result + + +def test_color_decorator_with_background(): + decorator = ColorDecorator(foreground="white", background="blue") + text = "Test text" + result = decorator.decorate(text) + assert result.startswith("\x1b[44m") # Blue background code + assert "\x1b[37m" in result # White foreground code + assert result.endswith("\x1b[0m") + assert "Test text" in result + + +def test_color_decorator_invalid_color(): + with pytest.raises(ValueError): + ColorDecorator(foreground="invalid") + + with pytest.raises(ValueError): + ColorDecorator(foreground="white", background="invalid") diff --git a/tests/rendering/decorators/test_style.py b/tests/rendering/decorators/test_style.py new file mode 100644 index 0000000..c8a54c1 --- /dev/null +++ b/tests/rendering/decorators/test_style.py @@ -0,0 +1,30 @@ +import pytest + +from command_line_assistant.rendering.decorators.style import StyleDecorator + + +def test_style_decorator_single_style(): + decorator = StyleDecorator("bright") + text = "Test text" + styled_text = decorator.decorate(text) + assert styled_text != text # Style was applied + assert len(styled_text) > len(text) # Reset code was added + + +def test_style_decorator_invalid_style(): + with pytest.raises(ValueError): + StyleDecorator("invalid_style") + + +def test_style_decorator_empty(): + decorator = StyleDecorator() + text = "Test text" + styled_text = decorator.decorate(text) + assert styled_text.endswith(text) # Text is at the end after any styles + + +def test_style_decorator_reset(): + decorator = StyleDecorator("bright") + text = "Test text" + styled_text = decorator.decorate(text) + assert "\x1b[1mTest text\x1b[0m" == styled_text diff --git a/tests/rendering/decorators/test_text.py b/tests/rendering/decorators/test_text.py new file mode 100644 index 0000000..95b9161 --- /dev/null +++ b/tests/rendering/decorators/test_text.py @@ -0,0 +1,29 @@ +from command_line_assistant.rendering.decorators.text import ( + EmojiDecorator, + TextWrapDecorator, +) + + +def test_text_wrap_decorator(): + decorator = TextWrapDecorator(width=10) + text = "This is a long text that should be wrapped" + decorated = decorator.decorate(text) + assert len(max(decorated.split("\n"), key=len)) <= 10 + + +def test_emoji_decorator(): + decorator = EmojiDecorator("⭐") + text = "Test text" + assert decorator.decorate(text) == "⭐ Test text" + + +def test_emoji_decorator_with_hex(): + decorator = EmojiDecorator(0x2728) # Sparkles emoji + text = "Test text" + assert decorator.decorate(text) == "✨ Test text" + + +def test_emoji_decorator_with_unicode(): + decorator = EmojiDecorator("U+1F4A5") # Collision emoji + text = "Test text" + assert decorator.decorate(text) == "💥 Test text" diff --git a/tests/rendering/test_render.py b/tests/rendering/test_render.py new file mode 100644 index 0000000..236d9b7 --- /dev/null +++ b/tests/rendering/test_render.py @@ -0,0 +1,75 @@ +from command_line_assistant.rendering.decorators.colors import ColorDecorator +from command_line_assistant.rendering.decorators.style import StyleDecorator +from command_line_assistant.rendering.decorators.text import TextWrapDecorator +from command_line_assistant.rendering.render import TextRenderer + + +def test_text_renderer_multiple_decorators(): + renderer = TextRenderer() + renderer.update(ColorDecorator(foreground="red")) + renderer.update(StyleDecorator("bright")) + renderer.update(TextWrapDecorator(width=50)) + + # Verify renderer has all decorators + assert len(renderer._decorators) == 3 + + +def test_text_renderer_decorator_override(): + renderer = TextRenderer() + renderer.update(ColorDecorator(foreground="red")) + renderer.update(ColorDecorator(foreground="blue")) + + # Verify last decorator of same type overrides previous + assert len(renderer._decorators) == 1 + + +def test_text_renderer_render_single_decorator(capsys): + renderer = TextRenderer() + renderer.update(ColorDecorator(foreground="red")) + + test_text = "Test message" + renderer.render(test_text) + + captured = capsys.readouterr() + assert test_text in captured.out + + +def test_text_renderer_render_multiple_decorators(capsys): + renderer = TextRenderer() + renderer.update(ColorDecorator(foreground="blue")) + renderer.update(StyleDecorator("bright")) + renderer.update(TextWrapDecorator(width=20)) + + test_text = "This is a test message that should be wrapped" + renderer.render(test_text) + + expected_text = ( + "\x1b[1m\x1b[34mThis is atest message thatshould bewrapped\x1b[0m\x1b[0m" + ) + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + assert expected_text in "".join(lines) + assert all(len(line) <= 20 for line in lines) + + +def test_text_renderer_render_empty_text(capsys): + renderer = TextRenderer() + renderer.update(ColorDecorator(foreground="green")) + + renderer.render("") + + captured = capsys.readouterr() + # TODO(r0x0d): right now, we are still applying the color and everything else. + # Maybe in the future we want to get rid of the formatting if we don't have text... + assert captured.out.strip() == "\x1b[32m\x1b[0m" + + +def test_text_renderer_render_multiline(capsys): + renderer = TextRenderer() + renderer.update(ColorDecorator(foreground="yellow")) + + test_text = "Line 1\nLine 2\nLine 3" + renderer.render(test_text) + + captured = capsys.readouterr() + assert len(captured.out.strip().split("\n")) == 3 diff --git a/tests/rendering/test_spinner.py b/tests/rendering/test_spinner.py new file mode 100644 index 0000000..f5fd004 --- /dev/null +++ b/tests/rendering/test_spinner.py @@ -0,0 +1,106 @@ +import sys +import threading +import time +from contextlib import contextmanager +from io import StringIO + +import pytest + +from command_line_assistant.rendering.spinner import Frames, ascii_spinner + + +@contextmanager +def capture_stdout(): + """Helper context manager to capture stdout for testing""" + stdout = StringIO() + old_stdout = sys.stdout + sys.stdout = stdout + try: + yield stdout + finally: + sys.stdout = old_stdout + + +def test_frames_default_values(): + """Test that Frames class has all expected default values""" + frames = Frames() + + # Test that all frame sequences exist + assert hasattr(frames, "default") + assert hasattr(frames, "braille") + assert hasattr(frames, "circular") + assert hasattr(frames, "dots") + assert hasattr(frames, "arrows") + assert hasattr(frames, "moving") + + +def test_frames_iteration(): + """Test that frame sequences can be iterated""" + frames = Frames() + + # Test default frames iteration + default_iterator = frames.default + first_frame = next(default_iterator) + assert first_frame in ["-", "\\", "|", "/"] + + # Test that it cycles + for _ in range(5): # More than number of frames + frame = next(default_iterator) + assert frame in ["-", "\\", "|", "/"] + + +def test_ascii_spinner_basic(): + """Test basic spinner functionality""" + with capture_stdout() as output: + with ascii_spinner("Loading", delay=0.1): + time.sleep(0.2) # Allow spinner to make at least one iteration + + captured = output.getvalue() + assert "Loading" in captured + assert "\r" in captured # Should use carriage return + + +def test_ascii_spinner_clear_message(): + """Test spinner with clear_message option""" + with capture_stdout() as output: + with ascii_spinner("Loading", clear_message=True, delay=0.1): + time.sleep(0.2) + + final_output = output.getvalue().split("\r")[-1] + assert len(final_output.strip()) == 0 # Should end with empty line + + +def test_ascii_spinner_custom_frames(): + """Test spinner with custom frames""" + custom_frames = iter(["A", "B", "C"]) + with capture_stdout() as output: + with ascii_spinner("Loading", frames=custom_frames, delay=0.1): + time.sleep(0.2) + + captured = output.getvalue() + assert any(frame in captured for frame in ["A", "B", "C"]) + + +def test_spinner_thread_cleanup(): + """Test that spinner properly cleans up its thread""" + initial_threads = threading.active_count() + + with ascii_spinner("Loading", delay=0.1): + time.sleep(0.2) + during_threads = threading.active_count() + assert during_threads > initial_threads # Should have one more thread + + time.sleep(0.2) # Give time for cleanup + after_threads = threading.active_count() + assert after_threads == initial_threads # Thread should be cleaned up + + +@pytest.mark.parametrize("delay", [0.1, 0.2, 0.5]) +def test_spinner_different_delays(delay): + """Test spinner with different delay values""" + start_time = time.time() + with ascii_spinner("Loading", delay=delay): + time.sleep(delay * 2) # Wait for at least 2 iterations + duration = time.time() - start_time + + assert duration >= delay * 2 diff --git a/tests/utils/test_cli.py b/tests/utils/test_cli.py index e4afe9c..f397221 100644 --- a/tests/utils/test_cli.py +++ b/tests/utils/test_cli.py @@ -29,39 +29,43 @@ def mock_select(*args, **kwargs): assert not cli.read_stdin() -@pytest.mark.parametrize( - ("input_args", "expected"), - [ - (["script_name"], []), - (["script_name", "history", "--clear"], ["history", "--clear"]), - (["script_name", "how to list files"], ["query", "how to list files"]), - ], -) -def test_add_default_command(input_args, expected): - """Test add_default_command with various inputs""" - args = cli.add_default_command(input_args) - assert args == expected +def test_create_argument_parser(): + """Test create_argument_parser returns parser and subparser""" + parser, commands_parser = cli.create_argument_parser() + assert parser is not None + assert commands_parser is not None + assert parser.description is not None + assert commands_parser.dest == "command" @pytest.mark.parametrize( - ("input_args", "expected"), + ("args", "expected"), [ - (["script_name", "query", "some text"], "query"), - (["script_name", "history", "--clear"], "history"), - (["script_name", "--version"], "--version"), - (["script_name", "--help"], "--help"), - (["script_name", "some text"], None), + (["c"], []), + (["c", "query", "test query"], ["query", "test query"]), + (["c", "how to list files?"], ["query", "how to list files?"]), + (["/usr/bin/c", "test query"], ["query", "test query"]), + # When we just call `c` and do anything, we print help + ( + [], + [], + ), + (["/usr/bin/c", "history"], ["history"]), ], ) -def test_subcommand_used(input_args, expected): - """Test _subcommand_used with various inputs""" - assert cli._subcommand_used(input_args) == expected +def test_add_default_command(args, expected): + """Test adding default 'query' command when no command is specified""" + assert cli.add_default_command(args) == expected -def test_create_argument_parser(): - """Test create_argument_parser returns parser and subparser""" - parser, commands_parser = cli.create_argument_parser() - assert parser is not None - assert commands_parser is not None - assert parser.description is not None - assert commands_parser.dest == "command" +@pytest.mark.parametrize( + ("argv", "expected"), + ( + (["query"], "query"), + (["--version"], "--version"), + (["--help"], "--help"), + (["--clear"], None), + ), +) +def test_subcommand_used(argv, expected): + assert cli._subcommand_used(argv) == expected