diff --git a/src/repoman/_version.py b/src/repoman/_version.py index ff9a2d4..bf15195 100644 --- a/src/repoman/_version.py +++ b/src/repoman/_version.py @@ -11,14 +11,17 @@ import sys from dataclasses import dataclass from importlib import metadata +from typing import TYPE_CHECKING -from rich.console import Console from rich.layout import Layout from rich.panel import Panel from rich.table import Table from rich.text import Text -from repoman.utils.theme.theme import set_theme +from repoman.utils.ui import get_console + +if TYPE_CHECKING: + from rich.console import Console @dataclass @@ -146,11 +149,26 @@ def get_debug_info() -> Environment: def _make_debug_layout(env: Environment) -> Layout: """Build a Layout for debug info: header + packages | env vars.""" - header_text = Text( - f"{env.interpreter_name} {env.interpreter_version} | {env.interpreter_path} | {env.platform}", - style="bold", + header_table = Table(highlight=True, box=None, show_header=False) + header_table.add_row( + Text("Interpreter Name", style="rosewater"), + Text(env.interpreter_name, style="bold"), + ) + header_table.add_row( + Text("Interpreter Version", style="rosewater"), + Text(env.interpreter_version, style="bold"), + ) + header_table.add_row( + Text("Interpreter Path", style="rosewater"), + Text(env.interpreter_path, style="bold"), + ) + header_table.add_row(Text("Platform", style="rosewater"), Text(env.platform, style="bold")) + header = Panel( + header_table, + title="Debug Information", + title_align="left", + border_style="bright_blue", ) - header = Panel(header_text, title="Debug Info", title_align="left", border_style="bright_blue") packages_table = Table( highlight=True, @@ -171,7 +189,7 @@ def _make_debug_layout(env: Environment) -> Layout: layout = Layout() layout.split_column( - Layout(header, name="header", size=5), + Layout(header, name="header", size=7), Layout(name="main", ratio=1), ) layout["main"].split_row( @@ -207,7 +225,7 @@ def _make_debug_panel(env: Environment) -> Panel: Text.assemble(*[Text(str(pkg), style="bold") for pkg in env.packages]), ) table.add_row( - Text("Enviroment Variables", style="rosewater"), + Text("Environment Variables", style="rosewater"), Text.assemble(*[Text(str(var), style="bold") for var in env.variables]), ) return Panel(table, title="Debug Information", title_align="left") @@ -216,11 +234,11 @@ def _make_debug_panel(env: Environment) -> Panel: def debug_info(console: Console | None = None) -> None: """Return debug information.""" if not console: - console = Console(theme=set_theme()) + console = get_console() env = get_debug_info() - from repoman.cli.messages.layout import ( # noqa: PLC0415 - deferred to avoid circular import + from repoman.utils.ui.layout import ( # noqa: PLC0415 - deferred to avoid circular import use_layout, ) diff --git a/src/repoman/cli/commands/config/show.py b/src/repoman/cli/commands/config/show.py index 17d8c11..5fd8f1b 100644 --- a/src/repoman/cli/commands/config/show.py +++ b/src/repoman/cli/commands/config/show.py @@ -11,9 +11,9 @@ key_not_in_template, warning_panel, ) -from repoman.cli.messages.layout import layout_config_show_template, use_layout from repoman.resources import get_copier_answers_template from repoman.utils.logging import get_logger_console +from repoman.utils.ui.layout import layout_config_show_template, use_layout app = Typer( add_completion=True, diff --git a/src/repoman/cli/commands/config/validate_.py b/src/repoman/cli/commands/config/validate_.py index 3fb2e8e..33458e9 100644 --- a/src/repoman/cli/commands/config/validate_.py +++ b/src/repoman/cli/commands/config/validate_.py @@ -14,10 +14,10 @@ schema_not_found_skipping_validation, warning_panel, ) -from repoman.cli.messages.layout import layout_validation_failed, use_layout from repoman.config import load_answers, missing_commit_for_copier_update from repoman.copier import ValidationReport, load_prompt_schema, validate_answers from repoman.utils.logging import get_logger_console +from repoman.utils.ui.layout import layout_validation_failed, use_layout app = Typer( add_completion=True, diff --git a/src/repoman/cli/messages/__init__.py b/src/repoman/cli/messages/__init__.py index 9d26c1f..cd09578 100644 --- a/src/repoman/cli/messages/__init__.py +++ b/src/repoman/cli/messages/__init__.py @@ -8,7 +8,7 @@ warning: warning_panel() — yellow panels for warnings. success: project_created(), project_updated(), command_created() — green panels. dry_run: dry_run_create(), dry_run_update(), dry_run_command_add() — blue panels. - layout: use_layout() and layout builders — multi-panel Layout for wide terminals. + repoman.utils.ui.layout: use_layout() and layout builders — multi-panel Layout for wide terminals. error_text: Pure string helpers (e.g. answers_file_not_found()) for panel content. capability: supports_unicode_markdown() — Unicode/terminal capability detection. """ diff --git a/src/repoman/cli/messages/dry_run.py b/src/repoman/cli/messages/dry_run.py index 8af6a45..23607f6 100644 --- a/src/repoman/cli/messages/dry_run.py +++ b/src/repoman/cli/messages/dry_run.py @@ -9,7 +9,7 @@ from rich.panel import Panel from rich.text import Text -from repoman.cli.messages.layout import ( +from repoman.utils.ui.layout import ( layout_dry_run_command_add, layout_dry_run_create, layout_dry_run_update, diff --git a/src/repoman/cli/messages/success.py b/src/repoman/cli/messages/success.py index 59a8f55..07bb44c 100644 --- a/src/repoman/cli/messages/success.py +++ b/src/repoman/cli/messages/success.py @@ -10,7 +10,7 @@ from rich.text import Text from repoman.cli.messages.capability import supports_unicode_markdown -from repoman.cli.messages.layout import ( +from repoman.utils.ui.layout import ( layout_command_created, layout_project_created, layout_project_updated, diff --git a/src/repoman/utils/logging.py b/src/repoman/utils/logging.py index 84a54b2..8c524b5 100644 --- a/src/repoman/utils/logging.py +++ b/src/repoman/utils/logging.py @@ -1,7 +1,6 @@ """Logging utilities for repoman CLI application.""" import os -import sys from datetime import UTC, datetime from logging import DEBUG, INFO, Formatter, Logger, getLogger from logging.handlers import RotatingFileHandler @@ -12,22 +11,7 @@ from rich.logging import RichHandler from repoman.config import Config -from repoman.utils.theme.theme import set_theme - - -def _is_running_in_pytest() -> bool: - """Check if code is running inside pytest. - - Returns: - True if running in pytest, False otherwise - """ - # Check for pytest in sys.modules or environment variable - # Also check if we're being imported during pytest collection - return ( - "pytest" in sys.modules - or "PYTEST_CURRENT_TEST" in os.environ - or any("pytest" in str(arg) for arg in sys.argv if isinstance(arg, str)) - ) +from repoman.utils.ui.console import get_console def _set_up_logger( @@ -61,18 +45,7 @@ def _set_up_logger( return module_logger if not console: - # In pytest, disable Rich formatting to avoid ANSI codes in test assertions - if _is_running_in_pytest(): - # Use a console that outputs plain text (no colors/formatting) - # Write to stdout instead of stderr so CliRunner can capture it - console = Console( - file=sys.stdout, - force_terminal=False, - legacy_windows=False, - no_color=True, - ) - else: - console = Console(theme=set_theme("dark")) + console = get_console() rich_handler = RichHandler(rich_tracebacks=True, console=console) @@ -139,6 +112,7 @@ def get_logger_console( # Fall back to default if conversion fails log_level = INFO root_logger = _set_up_logger( + console=console, use_rotating_file_handler=True, log_level=log_level, ) @@ -163,19 +137,7 @@ def get_logger_console( console = rich_handler.console return logger, console - # If no console was found and none was provided, create a new one if console is None: - # In pytest, disable Rich formatting to avoid ANSI codes in test assertions - if _is_running_in_pytest(): - # Use a console that outputs plain text (no colors/formatting) - # Write to stdout instead of stderr so CliRunner can capture it - console = Console( - file=sys.stdout, - force_terminal=False, - legacy_windows=False, - no_color=True, - ) - else: - console = Console() + console = get_console() return logger, console diff --git a/src/repoman/utils/ui/__init__.py b/src/repoman/utils/ui/__init__.py new file mode 100644 index 0000000..9f41f82 --- /dev/null +++ b/src/repoman/utils/ui/__init__.py @@ -0,0 +1,7 @@ +"""Shared Rich UI utilities for the repoman CLI.""" + +from repoman.utils.ui.console import get_console +from repoman.utils.ui.layout import use_layout +from repoman.utils.ui.theme.theme import set_theme + +__all__ = ["get_console", "set_theme", "use_layout"] diff --git a/src/repoman/utils/ui/console.py b/src/repoman/utils/ui/console.py new file mode 100644 index 0000000..2b4cfa0 --- /dev/null +++ b/src/repoman/utils/ui/console.py @@ -0,0 +1,50 @@ +"""Shared Rich console helpers for CLI output.""" + +import os +import sys + +from rich.console import Console + +from repoman.utils.ui.theme.theme import set_theme + +_console_cache: list[Console | None] = [None] + + +def _is_running_in_pytest() -> bool: + """Check if code is running inside pytest. + + Returns: + True if running in pytest, False otherwise + """ + # Check for pytest in sys.modules or environment variable + # Also check if we're being imported during pytest collection + return ( + "pytest" in sys.modules + or "PYTEST_CURRENT_TEST" in os.environ + or any("pytest" in str(arg) for arg in sys.argv if isinstance(arg, str)) + ) + + +def get_console() -> Console: + """Return the process-wide Rich console used by CLI rendering.""" + console = _console_cache[0] + + if console is not None: + if _is_running_in_pytest(): + console.file = sys.stdout + return console + + if _is_running_in_pytest(): + # Use a console that outputs plain text (no colors/formatting) + # Write to stdout instead of stderr so CliRunner can capture it + console = Console( + file=sys.stdout, + force_terminal=False, + legacy_windows=False, + no_color=True, + ) + else: + console = Console(theme=set_theme("dark")) + + _console_cache[0] = console + return console diff --git a/src/repoman/utils/ui/layout.py b/src/repoman/utils/ui/layout.py new file mode 100644 index 0000000..3179179 --- /dev/null +++ b/src/repoman/utils/ui/layout.py @@ -0,0 +1,280 @@ +"""Rich layout utilities for multi-panel terminal output. + +Provides helpers to build Layout-based output for wide terminals, with +fallback to single-panel output for narrow terminals or piped output. +""" + +from typing import TYPE_CHECKING, Any + +from rich.console import Console +from rich.json import JSON +from rich.layout import Layout +from rich.panel import Panel +from rich.syntax import Syntax +from rich.text import Text + +if TYPE_CHECKING: + from repoman.copier import ValidationReport + + +def use_layout(console: Console | None, min_width: int = 100) -> bool: + """Return True when Layout should be used instead of single-panel output. + + Uses Layout when the console is wide enough; otherwise falls back to + single-panel layout to avoid cramped output. + + Args: + console: The Rich Console instance, or None. + min_width: Minimum console width (in characters) to use Layout. + Defaults to 100. + + Returns: + True if Layout should be used; False for single-panel fallback. + """ + if console is None: + return False + width = getattr(console, "width", None) + return not (width is None or width < min_width) + + +def _make_two_panel_layout( + left_content: Any, + right_content: Any, + *, + left_title: str = "Summary", + right_title: str = "Details", + left_style: str = "green", + right_style: str = "green", +) -> Layout: + """Build a two-panel horizontal Layout. + + Args: + left_content: Rich renderable for the left panel. + right_content: Rich renderable for the right panel. + left_title: Panel title for the left side. + right_title: Panel title for the right side. + left_style: Border style for the left panel. + right_style: Border style for the right panel. + + Returns: + A Layout with two side-by-side panels. + """ + layout = Layout() + + left_panel = Panel(left_content, title=left_title, border_style=left_style) + right_panel = Panel(right_content, title=right_title, border_style=right_style) + + layout.split_row( + Layout(left_panel, name="left", ratio=1), + Layout(right_panel, name="right", ratio=1, minimum_size=40), + ) + return layout + + +def layout_project_created( + project_name: str, + output_dir: str, + copier_options_serializable: dict[str, Any], + next_steps_text: str, + _console: Console | None, + *, + use_unicode: bool, +) -> Layout: + """Build a two-panel Layout for project creation success.""" + prefix = "✓ " if use_unicode else "" + summary = Text( + f"{prefix}Project '{project_name}' created successfully in {output_dir}\n\nNext steps:\n{next_steps_text}", + style="green", + ) + json_content = JSON.from_data(copier_options_serializable, indent=2) + return _make_two_panel_layout( + summary, + json_content, + left_title="Success — Summary & Next Steps", + right_title="Copier Options", + left_style="green", + right_style="green", + ) + + +def layout_project_updated( + project_dir: str, + copier_options_serializable: dict[str, Any], + next_steps_text: str, + _console: Console | None, + *, + use_unicode: bool, +) -> Layout: + """Build a two-panel Layout for project update success.""" + prefix = "✓ " if use_unicode else "" + summary = Text( + f"{prefix}Project updated successfully in {project_dir}\n\nNext steps:\n{next_steps_text}", + style="green", + ) + json_content = JSON.from_data(copier_options_serializable, indent=2) + return _make_two_panel_layout( + summary, + json_content, + left_title="Success — Summary & Next Steps", + right_title="Copier Options", + left_style="green", + right_style="green", + ) + + +def layout_dry_run_create( + project_name: str, + output_dir: str, + template_path: str, + copier_options_serializable: dict[str, Any], + next_steps_text: str, +) -> Layout: + """Build a two-panel Layout for create dry-run.""" + summary = Text( + f"Would create project '{project_name}' in {output_dir}\n" + f"Using template: {template_path}\n\n" + f"Next steps:\n{next_steps_text}", + style="blue", + ) + json_content = JSON.from_data(copier_options_serializable, indent=2) + return _make_two_panel_layout( + summary, + json_content, + left_title="Dry Run — Summary & Next Steps", + right_title="Copier Options", + left_style="blue", + right_style="blue", + ) + + +def layout_dry_run_update( + project_dir: str, + answers_path: str, + template_path: str | None, + vcs_ref: str | None, + copier_options_serializable: dict[str, Any], + next_steps_text: str, +) -> Layout: + """Build a two-panel Layout for update dry-run.""" + template_line = f"Using template: {template_path}\n" if template_path else "Template: (from answers file)\n" + vcs_line = f"VCS ref: {vcs_ref}\n" if vcs_ref else "" + summary = Text( + f"Would update project in {project_dir}\n" + f"Using answers file: {answers_path}\n" + f"{template_line}{vcs_line}\n" + f"Next steps:\n{next_steps_text}", + style="blue", + ) + json_content = JSON.from_data(copier_options_serializable, indent=2) + return _make_two_panel_layout( + summary, + json_content, + left_title="Dry Run — Summary & Next Steps", + right_title="Copier Options", + left_style="blue", + right_style="blue", + ) + + +def layout_validation_failed(report: "ValidationReport") -> tuple[Layout, int]: + """Build a three-column Layout for config validate failure.""" + missing_text = Text( + "\n".join(f" • {k}" for k in report.missing_keys) if report.missing_keys else " (none)", + style="red", + ) + extra_text = Text( + "\n".join(f" • {k}" for k in report.extra_keys) if report.extra_keys else " (none)", + style="red", + ) + type_errors_text = Text( + "\n".join(f" • {e}" for e in report.type_errors) if report.type_errors else " (none)", + style="red", + ) + + # Rich Layout defaults to full terminal height; fix size so there is no extra whitespace. + content_lines = max( + len(report.missing_keys) or 1, + len(report.extra_keys) or 1, + len(report.type_errors) or 1, + ) + row_height = 2 + content_lines + + row_layout = Layout() + row_layout.split_row( + Layout( + Panel(missing_text, title="Missing Keys", border_style="red"), + name="missing", + ratio=1, + minimum_size=20, + ), + Layout( + Panel(extra_text, title="Extra Keys", border_style="red"), + name="extra", + ratio=1, + minimum_size=20, + ), + Layout( + Panel(type_errors_text, title="Type Errors", border_style="red"), + name="type_errors", + ratio=1, + minimum_size=25, + ), + ) + root = Layout() + root.split_column(Layout(row_layout, name="row", size=row_height)) + return root, row_height + + +def layout_command_created( + summary_section: str, + next_steps_section: str, + *, + use_unicode: bool, +) -> Layout: + """Build a two-panel Layout for command creation success.""" + prefix = "✓ " if use_unicode else "" + left_content = Text(f"{prefix}{summary_section}", style="green") + right_content = Text(next_steps_section, style="green") + return _make_two_panel_layout( + left_content, + right_content, + left_title="Success — Created Files", + right_title="Next Steps", + left_style="green", + right_style="green", + ) + + +def layout_config_show_template(raw_yaml: str, key_count: int) -> tuple[Layout, int]: + """Build a Layout for config show full template: summary header + YAML content.""" + header_text = Text(f"Template answers ({key_count} keys)", style="bold") + header = Panel(header_text, border_style="bright_blue") + syntax = Syntax(raw_yaml, "yaml", line_numbers=False) + content_panel = Panel(syntax, border_style="bright_blue") + # Fix content height so layout does not fill terminal with trailing whitespace. + yaml_lines = len(raw_yaml.splitlines()) or 1 + content_height = 2 + yaml_lines + total_height = 3 + content_height + layout = Layout() + layout.split_column( + Layout(header, name="header", size=3), + Layout(content_panel, name="content", size=content_height), + ) + return layout, total_height + + +def layout_dry_run_command_add( + summary_section: str, + context_section: str, +) -> Layout: + """Build a two-panel Layout for generator add dry-run.""" + left_content = Text(summary_section, style="blue") + right_content = Text(context_section, style="blue") + return _make_two_panel_layout( + left_content, + right_content, + left_title="Dry Run — Summary", + right_title="Template Context", + left_style="blue", + right_style="blue", + ) diff --git a/src/repoman/utils/ui/theme/__init__.py b/src/repoman/utils/ui/theme/__init__.py new file mode 100644 index 0000000..090830d --- /dev/null +++ b/src/repoman/utils/ui/theme/__init__.py @@ -0,0 +1 @@ +"""Theme utilities for repoman CLI application.""" diff --git a/src/repoman/utils/ui/theme/theme.py b/src/repoman/utils/ui/theme/theme.py new file mode 100644 index 0000000..74f8288 --- /dev/null +++ b/src/repoman/utils/ui/theme/theme.py @@ -0,0 +1,128 @@ +"""Theme configuration for the repoman CLI application.""" + +import math +from typing import TYPE_CHECKING, Any + +from catppuccin import PALETTE +from rich.theme import Theme + +if TYPE_CHECKING: + from rich.style import Style + +_RAMP_FIRST_STOP = 0.33 +_RAMP_SECOND_STOP = 0.66 +_RAMP_FINAL_SPAN = 0.34 + + +def color_rgb(color: Any) -> tuple[int, int, int]: + """Return an RGB tuple for Catppuccin color objects.""" + if hasattr(color, "rgb"): + return (color.rgb.r, color.rgb.g, color.rgb.b) + return _hex_to_rgb(color.hex) + + +def _hex_to_rgb(value: str) -> tuple[int, int, int]: + value = value.removeprefix("#") + return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16) + + +def _lerp_rgb(a: tuple[int, int, int], b: tuple[int, int, int], t: float) -> tuple[int, int, int]: + t = max(0.0, min(1.0, t)) + return ( + int(a[0] + (b[0] - a[0]) * t), + int(a[1] + (b[1] - a[1]) * t), + int(a[2] + (b[2] - a[2]) * t), + ) + + +def _create_theme(colors: Any) -> Theme: + styles: dict[str, Style | str] = { + # Base Catppuccin palette aliases + "rosewater": colors.rosewater.hex, + "flamingo": colors.flamingo.hex, + "pink": colors.pink.hex, + "mauve": colors.mauve.hex, + "red": colors.red.hex, + "maroon": colors.maroon.hex, + "peach": colors.peach.hex, + "yellow": colors.yellow.hex, + "green": colors.green.hex, + "teal": colors.teal.hex, + "sky": colors.sky.hex, + "sapphire": colors.sapphire.hex, + "blue": colors.blue.hex, + "lavender": colors.lavender.hex, + "text": colors.text.hex, + "subtext1": colors.subtext1.hex, + "subtext0": colors.subtext0.hex, + "overlay2": colors.overlay2.hex, + "overlay1": colors.overlay1.hex, + "overlay0": colors.overlay0.hex, + "surface2": colors.surface2.hex, + "surface1": colors.surface1.hex, + "surface0": colors.surface0.hex, + "base": colors.base.hex, + "mantle": colors.mantle.hex, + "crust": colors.crust.hex, + # UI roles + "ui.border": colors.overlay1.hex, + "ui.header": colors.overlay2.hex, + # Semantic roles + "tool.name": f"bold {colors.yellow.hex}", + "tool.args": f"{colors.subtext0.hex}", + "tool.ok": f"{colors.green.hex}", + "tool.fail": f"{colors.red.hex}", + "info": f"{colors.subtext0.hex}", + "muted": f"{colors.overlay0.hex}", + # Markdown roles + "markdown.strong": f"bold {colors.yellow.hex}", + "markdown.emphasis": f"italic {colors.peach.hex}", + "markdown.code": colors.sky.hex, + "markdown.code_block": colors.sky.hex, + "markdown.link": f"underline {colors.blue.hex}", + "markdown.h1": f"bold {colors.yellow.hex}", + "markdown.h2": f"bold {colors.peach.hex}", + "markdown.h3": f"bold {colors.maroon.hex}", + } + return Theme(styles=styles, inherit=True) + + +def set_theme(theme_name: str = "dark") -> Theme: + """Set the theme for the application.""" + if theme_name == "light": + theme = _create_theme(PALETTE.frappe.colors) + elif theme_name == "dark": + theme = _create_theme(PALETTE.mocha.colors) + else: + raise ValueError(f"Unknown theme: {theme_name}") + return theme + + +def cool_ramp(progress: float, colors: Any = PALETTE.mocha.colors) -> tuple[int, int, int]: + """Blend through cool Catppuccin colors for logo animation progress.""" + p = max(0.0, min(1.0, progress)) + text = color_rgb(colors.text) + sky = color_rgb(colors.sky) + blue = color_rgb(colors.blue) + lavender = color_rgb(colors.lavender) + if p < _RAMP_FIRST_STOP: + return _lerp_rgb(text, sky, p / _RAMP_FIRST_STOP) + + if p < _RAMP_SECOND_STOP: + return _lerp_rgb( + sky, + blue, + (p - _RAMP_FIRST_STOP) / _RAMP_FIRST_STOP, + ) + + return _lerp_rgb(blue, lavender, (p - _RAMP_SECOND_STOP) / _RAMP_FINAL_SPAN) + + +def catppuccin_hold_shimmer(t: float, settle_t: float, colors: Any) -> tuple[int, int, int]: + """Return a subtle blue-to-lavender shimmer color for settled animations.""" + blue = color_rgb(colors.blue) + lavender = color_rgb(colors.lavender) + shimmer = (1.0 - max(0.0, min(1.0, settle_t))) * (0.5 + 0.5 * math.sin(t * 4.0)) + shimmer *= 0.18 + + return _lerp_rgb(blue, lavender, shimmer) diff --git a/tests/test_cli/test_messages.py b/tests/test_cli/test_messages.py index ded18e9..e2e9793 100644 --- a/tests/test_cli/test_messages.py +++ b/tests/test_cli/test_messages.py @@ -34,7 +34,13 @@ dry_run_create, dry_run_update, ) -from repoman.cli.messages.layout import ( +from repoman.cli.messages.success import ( + command_created, + format_next_steps, + project_created, + project_updated, +) +from repoman.utils.ui.layout import ( layout_command_created, layout_config_show_template, layout_dry_run_command_add, @@ -45,12 +51,6 @@ layout_validation_failed, use_layout, ) -from repoman.cli.messages.success import ( - command_created, - format_next_steps, - project_created, - project_updated, -) # --- layout --- diff --git a/tests/test_utils/test_logging.py b/tests/test_utils/test_logging.py index 9d40f7f..36b314a 100644 --- a/tests/test_utils/test_logging.py +++ b/tests/test_utils/test_logging.py @@ -16,6 +16,7 @@ _set_up_logger, get_logger_console, ) +from repoman.utils.ui.console import get_console def _close_rotating_handlers(logger: Logger) -> None: @@ -54,6 +55,15 @@ def temp_log_dir(tmp_path: Path) -> Any: class TestSetUpLogger: """Test logger setup functionality.""" + def test_set_up_logger_uses_global_console(self) -> None: + """Test logger setup uses the shared global console by default.""" + console = get_console() + logger = _set_up_logger("test_logger_global_console") + + rich_handlers = [h for h in logger.handlers if h.get_name() == "rich"] + assert len(rich_handlers) == 1 + assert rich_handlers[0].console is console + def test_set_up_logger_basic(self) -> None: """Test basic logger setup without console.""" # Temporarily clear environment variable to test default behavior @@ -237,6 +247,10 @@ def test_attach_rotating_file_handler_multiple_calls(self) -> None: class TestGetLoggerConsole: """Test get_logger_console functionality.""" + def test_get_console_returns_singleton(self) -> None: + """Test repeated console lookups return the same instance.""" + assert get_console() is get_console() + def test_get_logger_console_basic(self) -> None: """Test basic get_logger_console functionality.""" logger, console = get_logger_console("test_logger") @@ -622,7 +636,7 @@ def test_set_up_logger_console_creation_not_in_pytest() -> None: This test verifies console creation path (line 75). """ # Mock _is_running_in_pytest to return False - with patch("repoman.utils.logging._is_running_in_pytest", return_value=False): + with patch("repoman.utils.ui.console._is_running_in_pytest", return_value=False): logger = _set_up_logger("test_logger_not_pytest") # Verify logger was created @@ -675,7 +689,7 @@ def test_get_logger_console_not_in_pytest() -> None: """ # Mock _is_running_in_pytest to return False with ( - patch("repoman.utils.logging._is_running_in_pytest", return_value=False), + patch("repoman.utils.ui.console._is_running_in_pytest", return_value=False), patch("repoman.utils.logging._set_up_logger") as mock_setup, ): # Mock root logger to have no handlers to trigger fallback diff --git a/tests/test_utils/test_theme.py b/tests/test_utils/test_theme.py index 153463a..67a1755 100644 --- a/tests/test_utils/test_theme.py +++ b/tests/test_utils/test_theme.py @@ -5,8 +5,7 @@ import pytest from rich.theme import Theme -from repoman.utils.theme.terminal_colors import get_rich_color -from repoman.utils.theme.theme import _create_theme, set_theme +from repoman.utils.ui.theme.theme import _create_theme, set_theme class TestThemeCreation: @@ -158,38 +157,6 @@ def test_set_theme_dark_vs_light_different(self) -> None: assert theme_dark.styles != theme_light.styles -class TestTerminalColors: - """Test terminal color mapping functionality.""" - - def test_get_rich_color_known_labels(self) -> None: - """Test that known labels return correct colors.""" - assert get_rich_color("TP") == "green" - assert get_rich_color("FP") == "maroon" - assert get_rich_color("FN") == "red" - assert get_rich_color("TN") == "peach" - assert get_rich_color("header") == "subtext1" - assert get_rich_color("border") == "overlay1" - - def test_get_rich_color_unknown_label(self) -> None: - """Test that unknown labels return default 'text' color.""" - assert get_rich_color("unknown") == "text" - assert get_rich_color("") == "text" - assert get_rich_color("CUSTOM_LABEL") == "text" - - def test_get_rich_color_case_sensitive(self) -> None: - """Test that color mapping is case sensitive.""" - assert get_rich_color("tp") == "text" # lowercase should return default - assert get_rich_color("Tp") == "text" # mixed case should return default - assert get_rich_color("TP") == "green" # exact match should work - - def test_get_rich_color_special_characters(self) -> None: - """Test that special characters in labels are handled correctly.""" - assert get_rich_color("TP_123") == "text" # alphanumeric with underscore - assert get_rich_color("TP-123") == "text" # alphanumeric with hyphen - assert get_rich_color("TP.123") == "text" # alphanumeric with dot - assert get_rich_color("TP 123") == "text" # alphanumeric with space - - class TestThemeErrorHandling: """Test error handling and edge cases in theme utilities.""" @@ -334,35 +301,6 @@ def test_set_theme_with_edge_case_names(self) -> None: with pytest.raises(ValueError, match="Unknown theme"): set_theme("light-中文") - def test_get_rich_color_with_edge_cases(self) -> None: - """Test get_rich_color with edge case inputs.""" - # Test with None - should return default since .get() handles None gracefully - result = get_rich_color(None) - assert result == "text" # Should return default - - # Test with empty string - result = get_rich_color("") - assert result == "text" # Should return default - - # Test with whitespace-only string - result = get_rich_color(" ") - assert result == "text" # Should return default - - # Test with very long strings - long_label = "a" * 1000 - result = get_rich_color(long_label) - assert result == "text" # Should return default - - # Test with unicode strings - unicode_label = "TP-🚀-中文" - result = get_rich_color(unicode_label) - assert result == "text" # Should return default - - # Test with special characters - special_label = "TP@#$%^&*()" - result = get_rich_color(special_label) - assert result == "text" # Should return default - def test_theme_creation_with_corrupted_color_data(self) -> None: """Test theme creation with corrupted or unexpected color data.""" diff --git a/tests/test_version.py b/tests/test_version.py index 340d6eb..3471a6e 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -21,7 +21,7 @@ get_version, version_info, ) -from repoman.utils.theme.theme import set_theme +from repoman.utils.ui import set_theme class TestVersionFunctions: @@ -212,13 +212,13 @@ def test_debug_info_creates_console_with_theme(self) -> None: def test_debug_info_wide_console_uses_layout(self) -> None: """Test debug_info with use_layout True uses _make_debug_layout (wide terminal).""" console = Console(theme=set_theme()) - with patch("repoman.cli.messages.layout.use_layout", return_value=True): + with patch("repoman.utils.ui.layout.use_layout", return_value=True): debug_info(console) # Should not raise; prints layout def test_debug_info_narrow_console_uses_panel(self) -> None: """Test debug_info with use_layout False uses _make_debug_panel (narrow terminal).""" console = Console(theme=set_theme()) - with patch("repoman.cli.messages.layout.use_layout", return_value=False): + with patch("repoman.utils.ui.layout.use_layout", return_value=False): debug_info(console) # Should not raise; prints panel