Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion cforge/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
# Third-Party
from pydantic import BaseModel
from pydantic_core import PydanticUndefined
from rich.console import Console
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
from rich.segment import Segment
from rich.measure import Measurement
from rich.table import Table
from rich.panel import Panel
from rich.syntax import Syntax
Expand Down Expand Up @@ -212,6 +214,37 @@ def make_authenticated_request(
# ------------------------------------------------------------------------------


class LineLimit:
"""A renderable that limits the number of lines after rich's wrapping."""

def __init__(self, renderable: RenderableType, max_lines: int):
"""Implement with the wrapped renderable and the max lines to render"""
self.renderable = renderable
self.max_lines = max_lines

def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
"""Hook the actual rendering to perform the per-line truncation"""

# Let rich render the content with proper wrapping
lines = console.render_lines(self.renderable, options, pad=False)

# Limit to max_lines
for i, line in enumerate(lines):
if i >= self.max_lines:
# Optionally add an ellipsis indicator
yield Segment("...")
break
yield from line
yield Segment.line()

def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement:
"""Hook the measurement of this entry to pass through to the wrapped
renderable
"""

return Measurement.get(console, options, self.renderable)


def print_json(data: Any, title: Optional[str] = None) -> None:
"""Pretty print JSON data with Rich.

Expand Down Expand Up @@ -245,12 +278,15 @@ def print_table(
console = get_console()
table = Table(title=title, show_header=True, header_style="bold magenta")
col_name_map = col_name_map or {}
max_lines = get_settings().table_max_lines

for column in columns:
table.add_column(col_name_map.get(column, column), style="cyan")

for item in data:
row = [str(item.get(col, "")) for col in columns]
if max_lines > 0:
row = [LineLimit(cell, max_lines=max_lines) for cell in row]
table.add_row(*row)

console.print(table)
Expand Down
3 changes: 3 additions & 0 deletions cforge/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ def _set_database_url_default(self) -> Self:

mcpgateway_bearer_token: Optional[str] = None

# Max number of lines for printed tables (<1 => infinite)
table_max_lines: int = 4


@lru_cache
def get_settings() -> CLISettings:
Expand Down
271 changes: 271 additions & 0 deletions tests/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
_INT_SENTINEL_DEFAULT,
AuthenticationError,
CLIError,
LineLimit,
get_app,
get_auth_token,
get_console,
Expand Down Expand Up @@ -134,6 +135,195 @@ def test_authentication_error(self) -> None:
assert isinstance(error, CLIError)


class TestLineLimit:
"""Tests for LineLimit class that truncates rendered content."""

def test_line_limit_basic_truncation(self) -> None:
"""Test that LineLimit truncates content to max_lines."""
from rich.text import Text
from rich.console import Console

console = Console()
# Create text with 5 lines
text = Text("Line 1\nLine 2\nLine 3\nLine 4\nLine 5")
limited = LineLimit(text, max_lines=3)

# Render to string and verify truncation
with console.capture() as capture:
console.print(limited)

output = capture.get()
# Should contain first 3 lines
assert "Line 1" in output
assert "Line 2" in output
assert "Line 3" in output
# Should NOT contain lines 4 and 5
assert "Line 4" not in output
assert "Line 5" not in output
# Should have ellipsis
assert "..." in output

def test_line_limit_no_truncation_needed(self) -> None:
"""Test that LineLimit doesn't truncate when content is within limit."""
from rich.text import Text
from rich.console import Console

console = Console()
# Create text with 2 lines, limit to 5
text = Text("Line 1\nLine 2")
limited = LineLimit(text, max_lines=5)

with console.capture() as capture:
console.print(limited)

output = capture.get()
# Should contain both lines
assert "Line 1" in output
assert "Line 2" in output
# Should NOT have ellipsis since no truncation
assert "..." not in output

def test_line_limit_exact_match(self) -> None:
"""Test LineLimit when content exactly matches max_lines."""
from rich.text import Text
from rich.console import Console

console = Console()
# Create text with exactly 3 lines
text = Text("Line 1\nLine 2\nLine 3")
limited = LineLimit(text, max_lines=3)

with console.capture() as capture:
console.print(limited)

output = capture.get()
# Should contain all 3 lines
assert "Line 1" in output
assert "Line 2" in output
assert "Line 3" in output
# Should NOT have ellipsis since content fits exactly
assert "..." not in output

def test_line_limit_zero_lines(self) -> None:
"""Test LineLimit with max_lines=0 shows only ellipsis."""
from rich.text import Text
from rich.console import Console

console = Console()
text = Text("Line 1\nLine 2")
limited = LineLimit(text, max_lines=0)

with console.capture() as capture:
console.print(limited)

output = capture.get()
# Should only show ellipsis, no content
assert "..." in output
assert "Line 1" not in output
assert "Line 2" not in output

def test_line_limit_one_line(self) -> None:
"""Test LineLimit with max_lines=1."""
from rich.text import Text
from rich.console import Console

console = Console()
text = Text("Line 1\nLine 2\nLine 3")
limited = LineLimit(text, max_lines=1)

with console.capture() as capture:
console.print(limited)

output = capture.get()
# Should show only first line and ellipsis
assert "Line 1" in output
assert "..." in output
assert "Line 2" not in output
assert "Line 3" not in output

def test_line_limit_with_long_single_line(self) -> None:
"""Test LineLimit with a single long line that wraps."""
from rich.text import Text
from rich.console import Console

console = Console(width=80) # Set fixed width for predictable wrapping
# Create a very long line that will wrap
long_text = "A" * 200
text = Text(long_text)
limited = LineLimit(text, max_lines=2)

with console.capture() as capture:
console.print(limited)

output = capture.get()
# Should contain some A's but be truncated
assert "A" in output
# Should have ellipsis since it wraps to more than 2 lines
assert "..." in output

def test_line_limit_measurement_passthrough(self) -> None:
"""Test that LineLimit passes through measurement to wrapped renderable."""
from rich.text import Text
from rich.console import Console

console = Console()
text = Text("Test content")
limited = LineLimit(text, max_lines=3)

# Get measurement using console's options
measurement = console.measure(limited)

# Should return a valid Measurement
assert measurement is not None
assert hasattr(measurement, "minimum")
assert hasattr(measurement, "maximum")

def test_line_limit_with_empty_content(self) -> None:
"""Test LineLimit with empty content."""
from rich.text import Text
from rich.console import Console

console = Console()
text = Text("")
limited = LineLimit(text, max_lines=3)

with console.capture() as capture:
console.print(limited)

output = capture.get()
# Empty content should produce minimal output
# Should not have ellipsis since there's nothing to truncate
assert "..." not in output

def test_line_limit_preserves_styling(self) -> None:
"""Test that LineLimit preserves rich styling in truncated content."""
from rich.text import Text
from rich.console import Console

console = Console()
# Create styled text
text = Text()
text.append("Line 1\n", style="bold red")
text.append("Line 2\n", style="italic blue")
text.append("Line 3\n", style="underline green")
text.append("Line 4", style="bold yellow")

limited = LineLimit(text, max_lines=2)

with console.capture() as capture:
console.print(limited)

output = capture.get()
# Should contain first 2 lines
assert "Line 1" in output
assert "Line 2" in output
# Should NOT contain lines 3 and 4
assert "Line 3" not in output
assert "Line 4" not in output
# Should have ellipsis
assert "..." in output


class TestMakeAuthenticatedRequest:
"""Tests for make_authenticated_request function using a server mock."""

Expand Down Expand Up @@ -285,6 +475,87 @@ def test_print_table_missing_columns(self, mock_console) -> None:
print_table(test_data, "Test Table", columns)
mock_console.print.assert_called_once()

def test_print_table_wraps_all_cells_with_line_limit(self) -> None:
"""Test that print_table wraps all cell values with LineLimit for truncation."""
from unittest.mock import patch

# Create test data with various types
test_data = [
{"id": 1, "name": "Item 1", "description": "Short text"},
{"id": 2, "name": "Item 2", "description": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"},
]
columns = ["id", "name", "description"]

# Mock Table.add_row to capture what's passed to it
with patch.object(Table, "add_row") as mock_add_row:
print_table(test_data, "Test Table", columns)

# Verify add_row was called for each data row
assert mock_add_row.call_count == 2

# Check that all arguments to add_row are LineLimit instances
for call in mock_add_row.call_args_list:
args = call[0] # Get positional arguments
for arg in args:
assert isinstance(arg, LineLimit), f"Expected LineLimit but got {type(arg)}"
# Verify max_lines is set to 4
assert arg.max_lines == 4

def test_print_table_with_custom_max_lines(self, mock_settings) -> None:
"""Test that print_table respects custom table_max_lines configuration."""
from unittest.mock import patch

# Configure mock_settings with custom max_lines value
mock_settings.table_max_lines = 2

test_data = [
{"id": 1, "name": "Item 1", "description": "Short text"},
{"id": 2, "name": "Item 2", "description": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"},
]
columns = ["id", "name", "description"]

# Mock Table.add_row to capture what's passed to it
with patch.object(Table, "add_row") as mock_add_row:
print_table(test_data, "Test Table", columns)

# Verify add_row was called for each data row
assert mock_add_row.call_count == 2

# Check that all arguments to add_row are LineLimit instances with custom max_lines
for call in mock_add_row.call_args_list:
args = call[0] # Get positional arguments
for arg in args:
assert isinstance(arg, LineLimit), f"Expected LineLimit but got {type(arg)}"
# Verify max_lines is set to custom value of 2
assert arg.max_lines == 2

def test_print_table_with_disabled_line_limit(self, mock_settings) -> None:
"""Test that print_table skips LineLimit wrapping when table_max_lines is 0 or negative."""
from unittest.mock import patch

# Configure mock_settings with disabled max_lines value (0)
mock_settings.table_max_lines = 0

test_data = [
{"id": 1, "name": "Item 1", "description": "Short text"},
{"id": 2, "name": "Item 2", "description": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"},
]
columns = ["id", "name", "description"]

# Mock Table.add_row to capture what's passed to it
with patch.object(Table, "add_row") as mock_add_row:
print_table(test_data, "Test Table", columns)

# Verify add_row was called for each data row
assert mock_add_row.call_count == 2

# Check that arguments to add_row are plain strings, NOT LineLimit instances
for call in mock_add_row.call_args_list:
args = call[0] # Get positional arguments
for arg in args:
assert isinstance(arg, str), f"Expected str but got {type(arg)}"
assert not isinstance(arg, LineLimit), "Should not wrap with LineLimit when disabled"


class TestPromptForSchema:
"""Tests for prompt_for_schema function."""
Expand Down