Skip to content

Commit a3aa236

Browse files
jcristcpcloud
authored andcommitted
feat: add max_columns option for table repr
1 parent 4a75988 commit a3aa236

File tree

4 files changed

+134
-67
lines changed

4 files changed

+134
-67
lines changed

ibis/backends/tests/test_client.py

Lines changed: 70 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pandas as pd
1010
import pandas.testing as tm
1111
import pytest
12+
import rich.console
1213
import sqlalchemy as sa
1314
from pytest import mark, param
1415

@@ -839,64 +840,94 @@ def test_dunder_array_column(alltypes, dtype):
839840

840841

841842
@pytest.mark.parametrize("interactive", [True, False])
842-
def test_repr(alltypes, interactive):
843+
def test_repr(alltypes, interactive, monkeypatch):
844+
monkeypatch.setattr(ibis.options, "interactive", interactive)
845+
843846
expr = alltypes.select("id", "int_col")
844847

845848
val = str(alltypes.limit(5).id.execute().iloc[0])
846849

847-
old = ibis.options.interactive
848-
ibis.options.interactive = interactive
849-
try:
850-
s = repr(expr)
851-
# no control characters
852-
assert all(c.isprintable() or c in "\n\r\t" for c in s)
853-
assert "id" in s
854-
if interactive:
855-
assert val in s
856-
else:
857-
assert val not in s
858-
finally:
859-
ibis.options.interactive = old
850+
s = repr(expr)
851+
# no control characters
852+
assert all(c.isprintable() or c in "\n\r\t" for c in s)
853+
assert "id" in s
854+
if interactive:
855+
assert val in s
856+
else:
857+
assert val not in s
860858

861859

862860
@pytest.mark.parametrize("show_types", [True, False])
863-
def test_interactive_repr_show_types(alltypes, show_types):
861+
def test_interactive_repr_show_types(alltypes, show_types, monkeypatch):
862+
monkeypatch.setattr(ibis.options, "interactive", True)
863+
monkeypatch.setattr(ibis.options.repr.interactive, "show_types", show_types)
864+
864865
expr = alltypes.select("id")
865-
old = ibis.options.interactive
866-
ibis.options.interactive = True
867-
ibis.options.repr.interactive.show_types = show_types
868-
try:
869-
s = repr(expr)
870-
if show_types:
871-
assert "int" in s
872-
else:
873-
assert "int" not in s
874-
finally:
875-
ibis.options.interactive = old
866+
s = repr(expr)
867+
if show_types:
868+
assert "int" in s
869+
else:
870+
assert "int" not in s
871+
872+
873+
@pytest.mark.parametrize("is_jupyter", [True, False])
874+
def test_interactive_repr_max_columns(alltypes, is_jupyter, monkeypatch):
875+
monkeypatch.setattr(ibis.options, "interactive", True)
876+
877+
cols = {f"c_{i}": ibis._.id + i for i in range(50)}
878+
expr = alltypes.mutate(**cols).select(*cols)
879+
880+
console = rich.console.Console(force_jupyter=is_jupyter)
881+
console.options.max_width = 80
882+
options = console.options.copy()
883+
884+
# max_columns = 0
885+
text = "".join(s.text for s in console.render(expr, options))
886+
assert " c_0 " in text
887+
if is_jupyter:
888+
# Default of 20 columns are written
889+
assert " c_19 " in text
890+
else:
891+
# width calculations truncates well before 20 columns
892+
assert " c_19 " not in text
893+
894+
# max_columns = 3
895+
monkeypatch.setattr(ibis.options.repr.interactive, "max_columns", 3)
896+
text = "".join(s.text for s in console.render(expr, options))
897+
assert " c_2 " in text
898+
assert " c_3 " not in text
899+
900+
# max_columns = None
901+
monkeypatch.setattr(ibis.options.repr.interactive, "max_columns", None)
902+
text = "".join(s.text for s in console.render(expr, options))
903+
assert " c_0 " in text
904+
if is_jupyter:
905+
# All columns written
906+
assert " c_49 " in text
907+
else:
908+
# width calculations still truncates
909+
assert " c_19 " not in text
876910

877911

878912
@pytest.mark.parametrize("expr_type", ["table", "column"])
879913
@pytest.mark.parametrize("interactive", [True, False])
880-
def test_repr_mimebundle(alltypes, interactive, expr_type):
914+
def test_repr_mimebundle(alltypes, interactive, expr_type, monkeypatch):
915+
monkeypatch.setattr(ibis.options, "interactive", interactive)
916+
881917
if expr_type == "column":
882918
expr = alltypes.id
883919
else:
884920
expr = alltypes.select("id", "int_col")
885921

886922
val = str(alltypes.limit(5).id.execute().iloc[0])
887923

888-
old = ibis.options.interactive
889-
ibis.options.interactive = interactive
890-
try:
891-
reprs = expr._repr_mimebundle_(include=["text/plain", "text/html"], exclude=[])
892-
for format in ["text/plain", "text/html"]:
893-
assert "id" in reprs[format]
894-
if interactive:
895-
assert val in reprs[format]
896-
else:
897-
assert val not in reprs[format]
898-
finally:
899-
ibis.options.interactive = old
924+
reprs = expr._repr_mimebundle_(include=["text/plain", "text/html"], exclude=[])
925+
for format in ["text/plain", "text/html"]:
926+
assert "id" in reprs[format]
927+
if interactive:
928+
assert val in reprs[format]
929+
else:
930+
assert val not in reprs[format]
900931

901932

902933
@pytest.mark.never(

ibis/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ class Interactive(Config):
7777
----------
7878
max_rows : int
7979
Maximum rows to pretty print.
80+
max_columns : int | None
81+
The maximum number of columns to pretty print. If 0 (the default), the
82+
number of columns will be inferred from output console size. Set to
83+
`None` for no limit.
8084
max_length : int
8185
Maximum length for pretty-printed arrays and maps.
8286
max_string : int
@@ -88,6 +92,7 @@ class Interactive(Config):
8892
"""
8993

9094
max_rows: int = 10
95+
max_columns: Optional[int] = 0
9196
max_length: int = 2
9297
max_string: int = 80
9398
max_depth: int = 1

ibis/expr/types/pretty.py

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,18 @@ def to_rich_table(table, console_width=None):
197197

198198
orig_ncols = len(table.columns)
199199

200-
# First determine the maximum subset of columns that *might* fit in the
201-
# current console. Note that not every column here may actually fit later
202-
# on once we know the repr'd width of the data.
203-
if console_width:
200+
max_columns = ibis.options.repr.interactive.max_columns
201+
if console_width == float("inf"):
202+
if max_columns == 0:
203+
# Show up to 20 columns by default, mirroring pandas's behavior
204+
if orig_ncols >= 20:
205+
table = table.select(*table.columns[:20])
206+
elif max_columns is not None and max_columns < orig_ncols:
207+
table = table.select(*table.columns[:max_columns])
208+
else:
209+
# Determine the maximum subset of columns that *might* fit in the
210+
# current console. Note that not every column here may actually fit
211+
# later on once we know the repr'd width of the data.
204212
computed_cols = []
205213
remaining = console_width - 1 # 1 char for left boundary
206214
for c in table.columns:
@@ -211,8 +219,12 @@ def to_rich_table(table, console_width=None):
211219
computed_cols.append(c)
212220
remaining -= needed
213221
else:
214-
table = table.select(*computed_cols)
215222
break
223+
if max_columns not in (0, None):
224+
# If an explicit limit on max columns is set, apply it
225+
computed_cols = computed_cols[:max_columns]
226+
if orig_ncols > len(computed_cols):
227+
table = table.select(*computed_cols)
216228

217229
# Compute the data and return a pandas dataframe
218230
nrows = ibis.options.repr.interactive.max_rows
@@ -269,29 +281,35 @@ def to_rich_table(table, console_width=None):
269281
# figure it out for us.
270282
columns_truncated = orig_ncols > len(col_info)
271283
col_widths = {}
272-
flex_cols = []
273-
remaining = console_width - 1
274-
if columns_truncated:
275-
remaining -= 4
276-
for name, _, min_width, max_width in col_info:
277-
remaining -= min_width + 3
278-
col_widths[name] = min_width
279-
if min_width != max_width:
280-
flex_cols.append((name, max_width))
281-
282-
while True:
283-
next_flex_cols = []
284-
for name, max_width in flex_cols:
285-
if remaining:
286-
remaining -= 1
287-
if max_width is not None:
288-
col_widths[name] += 1
289-
if max_width is None or col_widths[name] < max_width:
290-
next_flex_cols.append((name, max_width))
291-
else:
284+
if console_width == float("inf"):
285+
# Always use the max_width if there's infinite console space
286+
for name, _, _, max_width in col_info:
287+
col_widths[name] = max_width
288+
else:
289+
# Allocate the remaining space evenly between the flexible columns
290+
flex_cols = []
291+
remaining = console_width - 1
292+
if columns_truncated:
293+
remaining -= 4
294+
for name, _, min_width, max_width in col_info:
295+
remaining -= min_width + 3
296+
col_widths[name] = min_width
297+
if min_width != max_width:
298+
flex_cols.append((name, max_width))
299+
300+
while True:
301+
next_flex_cols = []
302+
for name, max_width in flex_cols:
303+
if remaining:
304+
remaining -= 1
305+
if max_width is not None:
306+
col_widths[name] += 1
307+
if max_width is None or col_widths[name] < max_width:
308+
next_flex_cols.append((name, max_width))
309+
else:
310+
break
311+
if not next_flex_cols:
292312
break
293-
if not next_flex_cols:
294-
break
295313

296314
rich_table = rich.table.Table(padding=(0, 1, 0, 1))
297315

@@ -313,6 +331,7 @@ def to_rich_table(table, console_width=None):
313331
justify="left",
314332
vertical="middle",
315333
width=1,
334+
min_width=1,
316335
no_wrap=True,
317336
)
318337

ibis/expr/types/relations.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,19 @@ def __rich_console__(self, console, options):
126126
if not ibis.options.interactive:
127127
return console.render(Text(self._repr()), options=options)
128128

129-
table = to_rich_table(self, options.max_width)
129+
if console.is_jupyter:
130+
# Rich infers a console width in jupyter notebooks, but since
131+
# notebooks can use horizontal scroll bars we don't want to apply a
132+
# limit here. Since rich requires an integer for max_width, we
133+
# choose an arbitrarily large integer bound. Note that we need to
134+
# handle this here rather than in `to_rich_table`, as this setting
135+
# also needs to be forwarded to `console.render`.
136+
options = options.update(max_width=1_000_000)
137+
width = None
138+
else:
139+
width = options.max_width
140+
141+
table = to_rich_table(self, width)
130142
return console.render(table, options=options)
131143

132144
def __getitem__(self, what):

0 commit comments

Comments
 (0)