Skip to content

Commit 25a7445

Browse files
authored
Merge pull request #10 from contextforge-org/ConciseTables-6
Concise tables 6
2 parents 97ddcb1 + acf7e6a commit 25a7445

3 files changed

Lines changed: 311 additions & 1 deletion

File tree

cforge/common.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
# Third-Party
1717
from pydantic import BaseModel
1818
from pydantic_core import PydanticUndefined
19-
from rich.console import Console
19+
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
20+
from rich.segment import Segment
21+
from rich.measure import Measurement
2022
from rich.table import Table
2123
from rich.panel import Panel
2224
from rich.syntax import Syntax
@@ -212,6 +214,37 @@ def make_authenticated_request(
212214
# ------------------------------------------------------------------------------
213215

214216

217+
class LineLimit:
218+
"""A renderable that limits the number of lines after rich's wrapping."""
219+
220+
def __init__(self, renderable: RenderableType, max_lines: int):
221+
"""Implement with the wrapped renderable and the max lines to render"""
222+
self.renderable = renderable
223+
self.max_lines = max_lines
224+
225+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
226+
"""Hook the actual rendering to perform the per-line truncation"""
227+
228+
# Let rich render the content with proper wrapping
229+
lines = console.render_lines(self.renderable, options, pad=False)
230+
231+
# Limit to max_lines
232+
for i, line in enumerate(lines):
233+
if i >= self.max_lines:
234+
# Optionally add an ellipsis indicator
235+
yield Segment("...")
236+
break
237+
yield from line
238+
yield Segment.line()
239+
240+
def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement:
241+
"""Hook the measurement of this entry to pass through to the wrapped
242+
renderable
243+
"""
244+
245+
return Measurement.get(console, options, self.renderable)
246+
247+
215248
def print_json(data: Any, title: Optional[str] = None) -> None:
216249
"""Pretty print JSON data with Rich.
217250
@@ -245,12 +278,15 @@ def print_table(
245278
console = get_console()
246279
table = Table(title=title, show_header=True, header_style="bold magenta")
247280
col_name_map = col_name_map or {}
281+
max_lines = get_settings().table_max_lines
248282

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

252286
for item in data:
253287
row = [str(item.get(col, "")) for col in columns]
288+
if max_lines > 0:
289+
row = [LineLimit(cell, max_lines=max_lines) for cell in row]
254290
table.add_row(*row)
255291

256292
console.print(table)

cforge/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ def _set_database_url_default(self) -> Self:
5959

6060
mcpgateway_bearer_token: Optional[str] = None
6161

62+
# Max number of lines for printed tables (<1 => infinite)
63+
table_max_lines: int = 4
64+
6265

6366
@lru_cache
6467
def get_settings() -> CLISettings:

tests/test_common.py

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
_INT_SENTINEL_DEFAULT,
2828
AuthenticationError,
2929
CLIError,
30+
LineLimit,
3031
get_app,
3132
get_auth_token,
3233
get_console,
@@ -134,6 +135,195 @@ def test_authentication_error(self) -> None:
134135
assert isinstance(error, CLIError)
135136

136137

138+
class TestLineLimit:
139+
"""Tests for LineLimit class that truncates rendered content."""
140+
141+
def test_line_limit_basic_truncation(self) -> None:
142+
"""Test that LineLimit truncates content to max_lines."""
143+
from rich.text import Text
144+
from rich.console import Console
145+
146+
console = Console()
147+
# Create text with 5 lines
148+
text = Text("Line 1\nLine 2\nLine 3\nLine 4\nLine 5")
149+
limited = LineLimit(text, max_lines=3)
150+
151+
# Render to string and verify truncation
152+
with console.capture() as capture:
153+
console.print(limited)
154+
155+
output = capture.get()
156+
# Should contain first 3 lines
157+
assert "Line 1" in output
158+
assert "Line 2" in output
159+
assert "Line 3" in output
160+
# Should NOT contain lines 4 and 5
161+
assert "Line 4" not in output
162+
assert "Line 5" not in output
163+
# Should have ellipsis
164+
assert "..." in output
165+
166+
def test_line_limit_no_truncation_needed(self) -> None:
167+
"""Test that LineLimit doesn't truncate when content is within limit."""
168+
from rich.text import Text
169+
from rich.console import Console
170+
171+
console = Console()
172+
# Create text with 2 lines, limit to 5
173+
text = Text("Line 1\nLine 2")
174+
limited = LineLimit(text, max_lines=5)
175+
176+
with console.capture() as capture:
177+
console.print(limited)
178+
179+
output = capture.get()
180+
# Should contain both lines
181+
assert "Line 1" in output
182+
assert "Line 2" in output
183+
# Should NOT have ellipsis since no truncation
184+
assert "..." not in output
185+
186+
def test_line_limit_exact_match(self) -> None:
187+
"""Test LineLimit when content exactly matches max_lines."""
188+
from rich.text import Text
189+
from rich.console import Console
190+
191+
console = Console()
192+
# Create text with exactly 3 lines
193+
text = Text("Line 1\nLine 2\nLine 3")
194+
limited = LineLimit(text, max_lines=3)
195+
196+
with console.capture() as capture:
197+
console.print(limited)
198+
199+
output = capture.get()
200+
# Should contain all 3 lines
201+
assert "Line 1" in output
202+
assert "Line 2" in output
203+
assert "Line 3" in output
204+
# Should NOT have ellipsis since content fits exactly
205+
assert "..." not in output
206+
207+
def test_line_limit_zero_lines(self) -> None:
208+
"""Test LineLimit with max_lines=0 shows only ellipsis."""
209+
from rich.text import Text
210+
from rich.console import Console
211+
212+
console = Console()
213+
text = Text("Line 1\nLine 2")
214+
limited = LineLimit(text, max_lines=0)
215+
216+
with console.capture() as capture:
217+
console.print(limited)
218+
219+
output = capture.get()
220+
# Should only show ellipsis, no content
221+
assert "..." in output
222+
assert "Line 1" not in output
223+
assert "Line 2" not in output
224+
225+
def test_line_limit_one_line(self) -> None:
226+
"""Test LineLimit with max_lines=1."""
227+
from rich.text import Text
228+
from rich.console import Console
229+
230+
console = Console()
231+
text = Text("Line 1\nLine 2\nLine 3")
232+
limited = LineLimit(text, max_lines=1)
233+
234+
with console.capture() as capture:
235+
console.print(limited)
236+
237+
output = capture.get()
238+
# Should show only first line and ellipsis
239+
assert "Line 1" in output
240+
assert "..." in output
241+
assert "Line 2" not in output
242+
assert "Line 3" not in output
243+
244+
def test_line_limit_with_long_single_line(self) -> None:
245+
"""Test LineLimit with a single long line that wraps."""
246+
from rich.text import Text
247+
from rich.console import Console
248+
249+
console = Console(width=80) # Set fixed width for predictable wrapping
250+
# Create a very long line that will wrap
251+
long_text = "A" * 200
252+
text = Text(long_text)
253+
limited = LineLimit(text, max_lines=2)
254+
255+
with console.capture() as capture:
256+
console.print(limited)
257+
258+
output = capture.get()
259+
# Should contain some A's but be truncated
260+
assert "A" in output
261+
# Should have ellipsis since it wraps to more than 2 lines
262+
assert "..." in output
263+
264+
def test_line_limit_measurement_passthrough(self) -> None:
265+
"""Test that LineLimit passes through measurement to wrapped renderable."""
266+
from rich.text import Text
267+
from rich.console import Console
268+
269+
console = Console()
270+
text = Text("Test content")
271+
limited = LineLimit(text, max_lines=3)
272+
273+
# Get measurement using console's options
274+
measurement = console.measure(limited)
275+
276+
# Should return a valid Measurement
277+
assert measurement is not None
278+
assert hasattr(measurement, "minimum")
279+
assert hasattr(measurement, "maximum")
280+
281+
def test_line_limit_with_empty_content(self) -> None:
282+
"""Test LineLimit with empty content."""
283+
from rich.text import Text
284+
from rich.console import Console
285+
286+
console = Console()
287+
text = Text("")
288+
limited = LineLimit(text, max_lines=3)
289+
290+
with console.capture() as capture:
291+
console.print(limited)
292+
293+
output = capture.get()
294+
# Empty content should produce minimal output
295+
# Should not have ellipsis since there's nothing to truncate
296+
assert "..." not in output
297+
298+
def test_line_limit_preserves_styling(self) -> None:
299+
"""Test that LineLimit preserves rich styling in truncated content."""
300+
from rich.text import Text
301+
from rich.console import Console
302+
303+
console = Console()
304+
# Create styled text
305+
text = Text()
306+
text.append("Line 1\n", style="bold red")
307+
text.append("Line 2\n", style="italic blue")
308+
text.append("Line 3\n", style="underline green")
309+
text.append("Line 4", style="bold yellow")
310+
311+
limited = LineLimit(text, max_lines=2)
312+
313+
with console.capture() as capture:
314+
console.print(limited)
315+
316+
output = capture.get()
317+
# Should contain first 2 lines
318+
assert "Line 1" in output
319+
assert "Line 2" in output
320+
# Should NOT contain lines 3 and 4
321+
assert "Line 3" not in output
322+
assert "Line 4" not in output
323+
# Should have ellipsis
324+
assert "..." in output
325+
326+
137327
class TestMakeAuthenticatedRequest:
138328
"""Tests for make_authenticated_request function using a server mock."""
139329

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

478+
def test_print_table_wraps_all_cells_with_line_limit(self) -> None:
479+
"""Test that print_table wraps all cell values with LineLimit for truncation."""
480+
from unittest.mock import patch
481+
482+
# Create test data with various types
483+
test_data = [
484+
{"id": 1, "name": "Item 1", "description": "Short text"},
485+
{"id": 2, "name": "Item 2", "description": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"},
486+
]
487+
columns = ["id", "name", "description"]
488+
489+
# Mock Table.add_row to capture what's passed to it
490+
with patch.object(Table, "add_row") as mock_add_row:
491+
print_table(test_data, "Test Table", columns)
492+
493+
# Verify add_row was called for each data row
494+
assert mock_add_row.call_count == 2
495+
496+
# Check that all arguments to add_row are LineLimit instances
497+
for call in mock_add_row.call_args_list:
498+
args = call[0] # Get positional arguments
499+
for arg in args:
500+
assert isinstance(arg, LineLimit), f"Expected LineLimit but got {type(arg)}"
501+
# Verify max_lines is set to 4
502+
assert arg.max_lines == 4
503+
504+
def test_print_table_with_custom_max_lines(self, mock_settings) -> None:
505+
"""Test that print_table respects custom table_max_lines configuration."""
506+
from unittest.mock import patch
507+
508+
# Configure mock_settings with custom max_lines value
509+
mock_settings.table_max_lines = 2
510+
511+
test_data = [
512+
{"id": 1, "name": "Item 1", "description": "Short text"},
513+
{"id": 2, "name": "Item 2", "description": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"},
514+
]
515+
columns = ["id", "name", "description"]
516+
517+
# Mock Table.add_row to capture what's passed to it
518+
with patch.object(Table, "add_row") as mock_add_row:
519+
print_table(test_data, "Test Table", columns)
520+
521+
# Verify add_row was called for each data row
522+
assert mock_add_row.call_count == 2
523+
524+
# Check that all arguments to add_row are LineLimit instances with custom max_lines
525+
for call in mock_add_row.call_args_list:
526+
args = call[0] # Get positional arguments
527+
for arg in args:
528+
assert isinstance(arg, LineLimit), f"Expected LineLimit but got {type(arg)}"
529+
# Verify max_lines is set to custom value of 2
530+
assert arg.max_lines == 2
531+
532+
def test_print_table_with_disabled_line_limit(self, mock_settings) -> None:
533+
"""Test that print_table skips LineLimit wrapping when table_max_lines is 0 or negative."""
534+
from unittest.mock import patch
535+
536+
# Configure mock_settings with disabled max_lines value (0)
537+
mock_settings.table_max_lines = 0
538+
539+
test_data = [
540+
{"id": 1, "name": "Item 1", "description": "Short text"},
541+
{"id": 2, "name": "Item 2", "description": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"},
542+
]
543+
columns = ["id", "name", "description"]
544+
545+
# Mock Table.add_row to capture what's passed to it
546+
with patch.object(Table, "add_row") as mock_add_row:
547+
print_table(test_data, "Test Table", columns)
548+
549+
# Verify add_row was called for each data row
550+
assert mock_add_row.call_count == 2
551+
552+
# Check that arguments to add_row are plain strings, NOT LineLimit instances
553+
for call in mock_add_row.call_args_list:
554+
args = call[0] # Get positional arguments
555+
for arg in args:
556+
assert isinstance(arg, str), f"Expected str but got {type(arg)}"
557+
assert not isinstance(arg, LineLimit), "Should not wrap with LineLimit when disabled"
558+
288559

289560
class TestPromptForSchema:
290561
"""Tests for prompt_for_schema function."""

0 commit comments

Comments
 (0)