Skip to content

Commit

Permalink
feat(ux): add Table and Column.preview()
Browse files Browse the repository at this point in the history
- unifies __interactive_rich_console__()
from Scalar, Column, and Relation into the parent Expr's __rich_console__(). This calls to_rich(),
which is reposnsible for turning an Expr into a rich object.
Now all implementations have the console.is_jupyter checks,
before only the Table did.
- propagates repr options downward through all the formatters in pretty.py,
  so you can pass hardcoded options instead of pulling from ibis.options.repr.interactive
- moves the check for ibis.options.repr.show_variables inside
  format.pretty(), the only place where it is used.
  • Loading branch information
NickCrews committed Apr 10, 2024
1 parent 356e459 commit 9ef131f
Show file tree
Hide file tree
Showing 5 changed files with 323 additions and 134 deletions.
12 changes: 9 additions & 3 deletions ibis/expr/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def __repr__(self):


@public
def pretty(expr: ops.Node | ir.Expr, scope: Optional[dict[str, ir.Expr]] = None):
def pretty(expr: ops.Node | ir.Expr, scope: Optional[dict[str, ir.Expr]] = None) -> str:
"""Pretty print an expression.
Parameters
Expand All @@ -178,8 +178,11 @@ def pretty(expr: ops.Node | ir.Expr, scope: Optional[dict[str, ir.Expr]] = None)
The expression to pretty print.
scope
A dictionary of expression to name mappings used to intermediate
assignments. If not provided, the names of the expressions will be
generated.
assignments.
If not provided the names of the expressions will either be
- the variable name in the defining scope if
`ibis.options.repr.show_variables` is enabled
- generated names like `r0`, `r1`, etc. otherwise
Returns
-------
Expand All @@ -193,6 +196,9 @@ def pretty(expr: ops.Node | ir.Expr, scope: Optional[dict[str, ir.Expr]] = None)
else:
raise TypeError(f"Expected an expression or a node, got {type(expr)}")

if scope is None and ibis.options.repr.show_variables:
scope = get_defining_scope(expr)

refs = {}
refcnt = itertools.count()
variables = {v.op(): k for k, v in (scope or {}).items()}
Expand Down
97 changes: 64 additions & 33 deletions ibis/expr/types/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@
from typing import TYPE_CHECKING, Any, NoReturn

from public import public
from rich.console import Console
from rich.jupyter import JupyterMixin
from rich.text import Text

import ibis
import ibis.expr.operations as ops
from ibis.common.annotations import ValidationError
from ibis.common.exceptions import IbisError, TranslationError
from ibis.common.grounds import Immutable
from ibis.common.patterns import Coercible, CoercionError
from ibis.config import _default_backend
from ibis.config import options as opts
from ibis.expr.types.pretty import to_rich
from ibis.util import experimental

if TYPE_CHECKING:
Expand Down Expand Up @@ -48,15 +52,68 @@ class Expr(Immutable, Coercible):
__slots__ = ("_arg",)
_arg: ops.Node

def __rich_console__(self, console, options):
if not opts.interactive:
from rich.text import Text
def _noninteractive_repr(self) -> str:
from ibis.expr.format import pretty

return pretty(self)

def _interactive_repr(self) -> str:
console = Console(force_terminal=False)
with console.capture() as capture:
try:
console.print(self)
except TranslationError as e:
lines = [
"Translation to backend failed",
f"Error message: {e!r}",
"Expression repr follows:",
self._noninteractive_repr(),
]
return "\n".join(lines)
return capture.get().rstrip()

return console.render(Text(repr(self)), options=options)
return self.__interactive_rich_console__(console, options)
def __repr__(self) -> str:
if ibis.options.interactive:
return self._interactive_repr()
else:
return self._noninteractive_repr()

def __rich_console__(self, console: Console, options):
if console.is_jupyter:
# Rich infers a console width in jupyter notebooks, but since
# notebooks can use horizontal scroll bars we don't want to apply a
# limit here. Since rich requires an integer for max_width, we
# choose an arbitrarily large integer bound. Note that we need to
# handle this here rather than in `to_rich`, as this setting
# also needs to be forwarded to `console.render`.
options = options.update(max_width=1_000_000)
console_width = None
else:
console_width = options.max_width

def __interactive_rich_console__(self, console, options):
raise NotImplementedError()
try:
if opts.interactive:
rich_object = to_rich(self, console_width=console_width)
else:
rich_object = Text(self._noninteractive_repr())
except Exception as e:
# In IPython exceptions inside of _repr_mimebundle_ are swallowed to
# allow calling several display functions and choosing to display
# the "best" result based on some priority.
# This behavior, though, means that exceptions that bubble up inside of the interactive repr
# are silently caught.
#
# We can't stop the exception from being swallowed, but we can force
# the display of that exception as we do here.
#
# A _very_ annoying caveat is that this exception is _not_ being
# ` raise`d, it is only being printed to the console. This means
# that you cannot "catch" it.
#
# This restriction is only present in IPython, not in other REPLs.
console.print_exception()
raise e
return console.render(rich_object, options=options)

def __init__(self, arg: ops.Node) -> None:
object.__setattr__(self, "_arg", arg)
Expand All @@ -73,32 +130,6 @@ def __coerce__(cls, value):
else:
raise CoercionError("Unable to coerce value to an expression")

def __repr__(self) -> str:
from ibis.expr.format import get_defining_scope, pretty

if opts.repr.show_variables:
scope = get_defining_scope(self)
else:
scope = None

if opts.interactive:
from ibis.expr.types.pretty import simple_console

with simple_console.capture() as capture:
try:
simple_console.print(self)
except TranslationError as e:
lines = [
"Translation to backend failed",
f"Error message: {e!r}",
"Expression repr follows:",
pretty(self, scope=scope),
]
return "\n".join(lines)
return capture.get().rstrip()
else:
return pretty(self, scope=scope)

def __reduce__(self):
return (self.__class__, (self._arg,))

Expand Down
75 changes: 57 additions & 18 deletions ibis/expr/types/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
from ibis.common.grounds import Singleton
from ibis.expr.rewrites import rewrite_window_input
from ibis.expr.types.core import Expr, _binop, _FixedTextJupyterMixin
from ibis.expr.types.pretty import to_rich
from ibis.util import deprecated, warn_deprecated

if TYPE_CHECKING:
import pandas as pd
import polars as pl
import pyarrow as pa
import rich.table

import ibis.expr.types as ir
from ibis.formats.pyarrow import PyArrowData
Expand Down Expand Up @@ -1178,20 +1180,6 @@ def to_pandas(self, **kwargs) -> pd.Series:

@public
class Scalar(Value):
def __interactive_rich_console__(self, console, options):
import rich.pretty

interactive = ibis.options.repr.interactive
return console.render(
rich.pretty.Pretty(
self.execute(),
max_length=interactive.max_length,
max_string=interactive.max_string,
max_depth=interactive.max_depth,
),
options=options,
)

def __pyarrow_result__(
self, table: pa.Table, data_mapper: type[PyArrowData] | None = None
) -> pa.Scalar:
Expand Down Expand Up @@ -1307,10 +1295,61 @@ def __getitem__(self, _):
def __array__(self, dtype=None):
return self.execute().__array__(dtype)

def __interactive_rich_console__(self, console, options):
named = self.name(self.op().name)
projection = named.as_table()
return console.render(projection, options=options)
def preview(
self,
*,
max_rows: int | None = None,
max_length: int | None = None,
max_string: int | None = None,
max_depth: int | None = None,
console_width: int | float | None = None,
) -> rich.table.Table:
"""Print a subset as a single-column Rich Table.
This is an explicit version of what you get when you inspect
this object in interactive mode, except with this version you
can pass formatting options. The options are the same as those exposed
in `ibis.options.interactive`.
Parameters
----------
max_rows
Maximum number of rows to display
max_length
Maximum length for pretty-printed arrays and maps.
max_string
Maximum length for pretty-printed strings.
max_depth
Maximum depth for nested data types.
console_width
Width of the console in characters. If not specified, the width
will be inferred from the console.
Examples
--------
>>> import ibis
>>> ibis.options.interactive = False
>>> t = ibis.examples.penguins.fetch()
>>> t.island.preview(max_rows=3, max_string=5)
┏━━━━━━━━┓
┃ island ┃
┡━━━━━━━━┩
│ stri… │
├────────┤
│ Torg… │
│ Torg… │
│ Torg… │
│ … │
└────────┘
"""
return to_rich(

Check warning on line 1345 in ibis/expr/types/generic.py

View check run for this annotation

Codecov / codecov/patch

ibis/expr/types/generic.py#L1345

Added line #L1345 was not covered by tests
self,
max_rows=max_rows,
max_length=max_length,
max_string=max_string,
max_depth=max_depth,
console_width=console_width,
)

def __pyarrow_result__(
self, table: pa.Table, data_mapper: type[PyArrowData] | None = None
Expand Down
Loading

0 comments on commit 9ef131f

Please sign in to comment.