Skip to content

Commit 1b8038c

Browse files
NickCrewscpcloud
andauthored
feat(ux): improve rich rendering (#11326)
Co-authored-by: Phillip Cloud <[email protected]>
1 parent 6d7defc commit 1b8038c

File tree

6 files changed

+191
-35
lines changed

6 files changed

+191
-35
lines changed

ibis/backends/tests/test_client.py

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from typing import TYPE_CHECKING
1515

1616
import pytest
17+
import rich
1718
import rich.console
1819
import sqlglot as sg
1920
import toolz
@@ -1112,21 +1113,84 @@ def test_dunder_array_column(alltypes, dtype):
11121113
np.testing.assert_array_equal(result, expected)
11131114

11141115

1115-
@pytest.mark.parametrize("interactive", [True, False])
1116-
def test_repr(alltypes, interactive, monkeypatch):
1117-
pytest.importorskip("rich")
1118-
1116+
@pytest.mark.parametrize(
1117+
"interactive", [True, False], ids=["interactive", "non_interactive"]
1118+
)
1119+
@pytest.mark.parametrize("is_jupyter", [True, False], ids=["jupyter", "not_jupyter"])
1120+
@pytest.mark.parametrize(
1121+
("method_name", "method"),
1122+
[
1123+
("repr", repr),
1124+
(
1125+
"mimebundle",
1126+
lambda wide_table: wide_table._repr_mimebundle_(["text/plain"], [])[
1127+
"text/plain"
1128+
],
1129+
),
1130+
(
1131+
"preview",
1132+
lambda wide_table: wide_table.preview()._repr_mimebundle_(None, None)[
1133+
"text/plain"
1134+
],
1135+
),
1136+
],
1137+
ids=["repr", "mimebundle", "preview"],
1138+
)
1139+
def test_reprs(alltypes, interactive, is_jupyter, method_name, method, monkeypatch):
11191140
monkeypatch.setattr(ibis.options, "interactive", interactive)
11201141

1121-
expr = alltypes.select("date_string_col")
1142+
# Depending on the order that tests are run, someone may have already
1143+
# called rich.get_console() and created the default console,
1144+
# which will have inferred is_jupyter from the environment,
1145+
# leading to False in pytest.
1146+
# 1. Make it so any newly-created Consoles will use the right value of `is_jupyter`
1147+
# 2. Update the default console to use the right value of `is_jupyter`
1148+
monkeypatch.setattr("rich.console._is_jupyter", lambda: is_jupyter)
1149+
default_console = rich.get_console()
1150+
new_console = rich.console.Console(force_jupyter=is_jupyter)
1151+
monkeypatch.setattr(default_console, "__dict__", new_console.__dict__)
1152+
expected_color_system = ("truecolor",) if is_jupyter else (None, "standard")
1153+
assert new_console.is_jupyter == is_jupyter
1154+
assert default_console.is_jupyter == is_jupyter
1155+
assert new_console.color_system in expected_color_system
1156+
assert default_console.color_system in expected_color_system
1157+
1158+
wide_table = alltypes.mutate(
1159+
*[alltypes[c].name(f"{c}_2") for c in alltypes.columns],
1160+
*[alltypes[c].name(f"{c}_3") for c in alltypes.columns],
1161+
)
11221162

1123-
s = repr(expr)
1124-
# no control characters
1125-
assert all(c.isprintable() or c in "\n\r\t" for c in s)
1126-
if interactive:
1127-
assert "/" in s
1163+
s = method(wide_table)
1164+
1165+
color_or_control_codes = {c for c in s if not c.isprintable() and c not in "\n\r\t"}
1166+
if (method_name == "mimebundle" and interactive and is_jupyter) or (
1167+
method_name == "preview" and is_jupyter
1168+
):
1169+
assert color_or_control_codes
11281170
else:
1129-
assert "/" not in s
1171+
assert not color_or_control_codes
1172+
1173+
# ┃ characters separate columns in the table
1174+
n_columns_seen = max(line.count("┃") for line in s.splitlines()) - 1
1175+
n_columns_seen = max(n_columns_seen, 0)
1176+
1177+
n_wide_columns = len(wide_table.columns)
1178+
n_original_columns = len(alltypes.columns)
1179+
1180+
assert (
1181+
# preview always shows exactly what was requested
1182+
(method_name == "preview" and n_columns_seen == n_wide_columns)
1183+
or (
1184+
interactive
1185+
and (
1186+
# table should have unbounded width, every column should be shown
1187+
(is_jupyter and n_columns_seen == n_wide_columns)
1188+
# table should be truncated to fit the console width
1189+
or n_columns_seen < n_original_columns
1190+
)
1191+
)
1192+
or not n_columns_seen
1193+
)
11301194

11311195

11321196
@pytest.mark.parametrize("show_types", [True, False])

ibis/expr/types/_rich.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,28 @@ def to_rich_table(
412412
if not next_flex_cols:
413413
break
414414

415-
rich_table = rich.table.Table(padding=(0, 1, 0, 1))
415+
# If the console width is unbounded, then explicitly make each column
416+
# take up its full width.
417+
# Otherwise, when you render the returned rich.Table in a rich.Console,
418+
# if the Console width is skinny, each table might be scrunched.
419+
# This happens for example when display()ing a rich.Table in a Jupyter notebook,
420+
# and you end up with
421+
# ┏┳┳━━━━━┳━━━━┳━━━━━━━━┳━━┳┳┳┳┳┳┳┳┳┳┳┳┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳━┳┳━┳┳━┳┳━┓
422+
# ┃┃┃ bi… ┃ b… ┃ flipp… ┃ ┃┃┃┃┃┃┃┃┃┃┃┃┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃┃ ┃┃ ┃┃ ┃
423+
# ┡╇╇━━━━━╇━━━━╇━━━━━━━━╇━━╇╇╇╇╇╇╇╇╇╇╇╇╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇━╇╇━╇╇━╇╇━┩
424+
# │││ fl… │ f… │ int64 │ │││││││││││││ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ││ ││ ││ │
425+
# ├┼┼─────┼────┼────────┼──┼┼┼┼┼┼┼┼┼┼┼┼┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼┼─┼┼─┼┼─┤
426+
# │││ 39… │ 1… │ 181 │ │││││││││││││ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ││ ││ ││ │
427+
# │││ 39… │ 1… │ 186 │ │││││││││││││ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ││ ││ ││ │
428+
# │││ 40… │ 1… │ 195 │ │││││││││││││ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ││ ││ ││ │
429+
# └┴┴─────┴────┴────────┴──┴┴┴┴┴┴┴┴┴┴┴┴┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴┴─┴┴─┴┴─┘
430+
# unless you set the width of the table explicitly.
431+
table_width = (
432+
1 + sum(col_widths.values()) + len(col_info) * 3
433+
if console_width == float("inf")
434+
else None
435+
)
436+
rich_table = rich.table.Table(padding=(0, 1, 0, 1), width=table_width)
416437

417438
# Configure the columns on the rich table.
418439
for name, dtype, _, max_width in col_info:

ibis/expr/types/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def _noninteractive_repr(self) -> str:
5151

5252
def __repr__(self) -> str:
5353
if ibis.options.interactive:
54-
return capture_rich_renderable(self)
54+
return capture_rich_renderable(self, no_color=True)["text/plain"]
5555
else:
5656
return self._noninteractive_repr()
5757

ibis/expr/types/generic.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from ibis.common.grounds import Singleton
1515
from ibis.expr.rewrites import rewrite_window_input
1616
from ibis.expr.types.core import Expr, _binop
17-
from ibis.expr.types.rich import FixedTextJupyterMixin, to_rich
17+
from ibis.expr.types.rich import RichJupyterMixin, to_rich
1818
from ibis.util import deprecated, experimental, promote_list
1919

2020
if TYPE_CHECKING:
@@ -1537,7 +1537,7 @@ def _repr_html_(self) -> str | None:
15371537

15381538

15391539
@public
1540-
class Column(Value, FixedTextJupyterMixin):
1540+
class Column(Value, RichJupyterMixin):
15411541
# Higher than numpy objects
15421542
__array_priority__ = 20
15431543

ibis/expr/types/relations.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from ibis.expr.rewrites import DerefMap
2424
from ibis.expr.types.core import Expr
2525
from ibis.expr.types.generic import Value, literal
26-
from ibis.expr.types.rich import FixedTextJupyterMixin, to_rich
26+
from ibis.expr.types.rich import RichJupyterMixin, to_rich
2727
from ibis.expr.types.temporal import TimestampColumn
2828
from ibis.util import deprecated, experimental
2929

@@ -144,7 +144,7 @@ def unwrap_aliases(values: Iterator[ir.Value]) -> Mapping[str, ir.Value]:
144144

145145

146146
@public
147-
class Table(Expr, FixedTextJupyterMixin):
147+
class Table(Expr, RichJupyterMixin):
148148
"""An immutable and lazy dataframe.
149149
150150
Analogous to a SQL table or a pandas DataFrame. A table expression contains

ibis/expr/types/rich.py

Lines changed: 89 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,45 +6,52 @@
66

77
from __future__ import annotations
88

9+
import contextlib
910
from typing import TYPE_CHECKING
1011

1112
from ibis.expr import types as ir
1213

1314
if TYPE_CHECKING:
15+
from collections.abc import Sequence
16+
1417
import rich.panel
1518
import rich.table
1619
from rich.console import RenderableType
1720

1821

1922
try:
20-
from rich.jupyter import JupyterMixin
23+
import rich
2124
except ImportError:
2225

23-
class FixedTextJupyterMixin:
26+
class RichJupyterMixin:
2427
"""No-op when rich is not installed."""
2528
else:
2629

27-
class FixedTextJupyterMixin(JupyterMixin):
28-
"""JupyterMixin adds a spurious newline to text, this fixes the issue."""
30+
class RichJupyterMixin:
31+
"""Adds `_repr_mimebundle_()` to anything with a `__rich_console__()`."""
2932

30-
def _repr_mimebundle_(self, *args, **kwargs):
31-
try:
32-
bundle = super()._repr_mimebundle_(*args, **kwargs)
33-
except Exception: # noqa: BLE001
34-
return None
35-
else:
36-
bundle["text/plain"] = bundle["text/plain"].rstrip()
37-
return bundle
33+
def _repr_mimebundle_(
34+
self, include: Sequence[str], exclude: Sequence[str], **kwargs
35+
) -> dict[str, str]:
36+
bundle = capture_rich_renderable(self, no_color=False)
37+
return {k: bundle[k] for k in (bundle.keys() & include).difference(exclude)}
3838

3939

40-
def capture_rich_renderable(renderable: RenderableType) -> str:
41-
"""Convert a rich renderable (has a __rich_console__(), etc) to a string."""
40+
def capture_rich_renderable(
41+
renderable: RenderableType, *, no_color: bool
42+
) -> dict[str, str]:
43+
"""Convert a rich renderable (has a __rich_console__(), etc) to text and html representations."""
4244
from rich.console import Console
4345

44-
console = Console(force_terminal=False)
45-
with console.capture() as capture:
46-
console.print(renderable)
47-
return capture.get().rstrip()
46+
color_system = None if no_color else "auto"
47+
console = Console()
48+
width = console.width
49+
if console.is_jupyter:
50+
width = 1_000_000
51+
with _with_rich_configured(
52+
width=width, color_system=color_system, force_terminal=False
53+
):
54+
return _RichMimeBundler(renderable).get_mimebundle()
4855

4956

5057
def to_rich(
@@ -74,3 +81,67 @@ def to_rich(
7481
max_depth=max_depth,
7582
console_width=console_width,
7683
)
84+
85+
86+
@contextlib.contextmanager
87+
def _with_rich_configured(**config):
88+
"""Context manager to temporarily configure rich."""
89+
from rich.console import Console
90+
91+
global_console = rich.get_console()
92+
new_console = Console(**config)
93+
original_config = global_console.__dict__
94+
95+
try:
96+
global_console.__dict__ = new_console.__dict__
97+
yield
98+
finally:
99+
global_console.__dict__ = original_config
100+
101+
102+
try:
103+
from rich.jupyter import JupyterMixin
104+
except ImportError:
105+
JupyterMixin = object
106+
107+
108+
class _RichMimeBundler(JupyterMixin):
109+
def __init__(self, renderable: RenderableType):
110+
self.renderable = renderable
111+
112+
def __rich_console__(self, console, options):
113+
yield self.renderable
114+
115+
def get_mimebundle(self) -> dict[str, str]:
116+
with _with_rich_display_disabled():
117+
bundle = super()._repr_mimebundle_(include=None, exclude=None)
118+
bundle["text/plain"] = bundle["text/plain"].rstrip() # Remove trailing newline
119+
return bundle
120+
121+
122+
@contextlib.contextmanager
123+
def _with_rich_display_disabled():
124+
"""Workaround to keep rich from doing spurious display() calls in Jupyter.
125+
126+
When you display(ibis.Table), without this, an extra output cell is created
127+
in the notebook. With this, there is no extra output cell.
128+
129+
See https://github.com/Textualize/rich/pull/3329
130+
"""
131+
try:
132+
from IPython import display as ipython_display
133+
except ImportError:
134+
# IPython is not installed, so nothing to do
135+
yield
136+
else:
137+
138+
def noop_display(*args, **kwargs):
139+
pass
140+
# ipython_display.display(*args, **kwargs)
141+
142+
original_display = ipython_display.display
143+
try:
144+
ipython_display.display = noop_display
145+
yield
146+
finally:
147+
ipython_display.display = original_display

0 commit comments

Comments
 (0)