diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a24e53c..d650f75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Changelog ### Next +* feat(ui): add modern Terminal User Interface (TUI) with `rich` tables for beautiful output formatting * Add unified `kaggle search` command across competitions, datasets, notebooks, models, users, and discussions * Add `--wait`/`--poll-interval` to `kaggle competitions submit` to wait for scoring, and add `kaggle competitions submission ` to look up a single submission's status and score diff --git a/README.md b/README.md index ebfa0ab8..bc26dd46 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Some of the key features are: * List, create, update, download or delete models & model variations. * List, update & run, download code & output or delete kernels (notebooks). * Browse and read discussion forums. +* **Modern Terminal User Interface (TUI)**: Beautiful and responsive output tables out of the box using `rich`. ## Installation diff --git a/documentation/intro.md b/documentation/intro.md new file mode 100644 index 00000000..64df61da --- /dev/null +++ b/documentation/intro.md @@ -0,0 +1,26 @@ +# Kaggle CLI TUI (Terminal User Interface) + +Kaggle CLI now ships with a built-in modern Terminal User Interface (TUI) powered by [`rich`](https://github.com/Textualize/rich). + +## Visual Enhancements + +When you run list commands (e.g., `kaggle datasets list`, `kaggle competitions list`, etc.) in an interactive terminal, the CLI will format the output as a gorgeous table with: +- **Rounded Borders:** Clean and modern look. +- **Color Coding:** Headers in bright cyan, borders in bright black. +- **Smart Alignment:** Numeric columns (like sizes, download counts, and vote counts) are automatically right-aligned for easier scanning. +- **Graceful Text Wrapping:** Content that overflows is neatly wrapped. + +## Automatic Fallback (No-breaking changes) + +The new UI is completely **safe for automation scripts**. The CLI automatically detects if it is being piped into another command (e.g., `kaggle datasets list > data.txt` or `kaggle datasets list | grep ...`). + +If standard output is not a TTY terminal, or if the `rich` library is not available, the CLI will seamlessly and silently fall back to the raw, uncolored string formatting. This guarantees 100% backward compatibility for all your existing scripts. + +## Supported Commands +The modern UI affects the output of all list-oriented commands, including: +- `kaggle competitions list` +- `kaggle datasets list` +- `kaggle kernels list` +- `kaggle models list` +- `kaggle models instances` +- `kaggle config view` diff --git a/pyproject.toml b/pyproject.toml index a19544eb..fa56dbeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "protobuf", "jupytext", "python-dotenv", + "rich >= 13.0.0", ] [project.scripts] diff --git a/src/kaggle/api/kaggle_api_extended.py b/src/kaggle/api/kaggle_api_extended.py index bf424ec8..c2da2ef2 100644 --- a/src/kaggle/api/kaggle_api_extended.py +++ b/src/kaggle/api/kaggle_api_extended.py @@ -8705,6 +8705,17 @@ def print_table(self, items, fields, labels=None): """ if labels is None: labels = fields + + try: + from kaggle.ui import print_rich_table, RICH_AVAILABLE + if RICH_AVAILABLE: + def attr_getter(i, f): + return getattr(i, self.camel_to_snake(f)) + if print_rich_table(items, fields, labels, string_formatter=self.string, attr_getter=attr_getter): + return + except ImportError: + pass + formats = [] borders = [] if len(items) == 0: diff --git a/src/kaggle/ui.py b/src/kaggle/ui.py new file mode 100644 index 00000000..8427ef2d --- /dev/null +++ b/src/kaggle/ui.py @@ -0,0 +1,74 @@ +import sys +from typing import List, Any, Optional + +try: + from rich.console import Console + from rich.table import Table + from rich.panel import Panel + from rich.theme import Theme + from rich.text import Text + + custom_theme = Theme({ + "info": "cyan", + "warning": "yellow", + "error": "bold red", + "success": "bold green" + }) + console = Console(theme=custom_theme) + RICH_AVAILABLE = True +except ImportError: + RICH_AVAILABLE = False + console = None + +def print_rich_table(items: List[Any], fields: List[str], labels: Optional[List[str]] = None, string_formatter=None, attr_getter=None): + if not RICH_AVAILABLE: + return False + + if labels is None: + labels = fields + + if not items: + console.print(Panel("No data found.", style="warning", expand=False)) + return True + + from rich import box + table = Table(show_header=True, header_style="bold bright_cyan", border_style="bright_black", box=box.ROUNDED) + + for i, label in enumerate(labels): + field = fields[i].lower() + justify = "right" if field in ["size", "reward", "id", "downloadcount", "votecount"] else "left" + table.add_column(label, justify=justify) + + for item in items: + row = [] + for field in fields: + val = attr_getter(item, field) if attr_getter else getattr(item, field) + val_str = string_formatter(val) if string_formatter else str(val) + row.append(val_str) + table.add_row(*row) + + console.print(table) + return True + +def print_info(message: str): + if RICH_AVAILABLE: + console.print(message, style="info") + else: + print(message) + +def print_error(message: str, exc: Optional[Exception] = None): + if RICH_AVAILABLE: + if exc: + console.print(f"[error]Error:[/error] {message}\n[dim]{str(exc)}[/dim]") + else: + console.print(f"[error]Error:[/error] {message}") + else: + print(f"Error: {message}", file=sys.stderr) + if exc: + print(str(exc), file=sys.stderr) + +def print_success(message: str): + if RICH_AVAILABLE: + console.print(f"✅ [success]{message}[/success]") + else: + print(message) diff --git a/tests/unit/test_ui.py b/tests/unit/test_ui.py new file mode 100644 index 00000000..403a8ec6 --- /dev/null +++ b/tests/unit/test_ui.py @@ -0,0 +1,41 @@ +import pytest +from unittest.mock import patch, MagicMock +from kaggle.ui import print_rich_table, print_info, print_error, print_success, RICH_AVAILABLE + +class DummyItem: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + +def test_print_rich_table_no_data(): + with patch("kaggle.ui.console") as mock_console: + assert print_rich_table([], ["id", "name"]) is True + mock_console.print.assert_called_once() + assert "No data found" in str(mock_console.print.call_args) + +def test_print_rich_table_with_data(): + items = [DummyItem(id=1, name="Test")] + with patch("kaggle.ui.console") as mock_console: + assert print_rich_table(items, ["id", "name"], ["ID", "Name"]) is True + mock_console.print.assert_called_once() + table_arg = mock_console.print.call_args[0][0] + # It should be a Table object + from rich.table import Table + assert isinstance(table_arg, Table) + assert len(table_arg.columns) == 2 + assert len(table_arg.rows) == 1 + +def test_print_info(): + with patch("kaggle.ui.console") as mock_console: + print_info("Hello") + mock_console.print.assert_called_once_with("Hello", style="info") + +def test_print_error(): + with patch("kaggle.ui.console") as mock_console: + print_error("Error msg") + mock_console.print.assert_called_once_with("[error]Error:[/error] Error msg") + +def test_print_success(): + with patch("kaggle.ui.console") as mock_console: + print_success("Done") + mock_console.print.assert_called_once_with("✅ [success]Done[/success]")