Skip to content

Commit

Permalink
feat(api): use rich for interactive __repr__
Browse files Browse the repository at this point in the history
  • Loading branch information
cpcloud authored and kszucs committed Sep 20, 2022
1 parent 1e6f6e7 commit 04758b8
Show file tree
Hide file tree
Showing 14 changed files with 269 additions and 56 deletions.
13 changes: 12 additions & 1 deletion ibis/backends/duckdb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, Iterator, MutableMapping

import pandas as pd
import pyarrow.types as pat
import sqlalchemy as sa
import toolz

Expand Down Expand Up @@ -188,7 +190,16 @@ def fetch_from_cursor(
schema: sch.Schema,
):
table = cursor.cursor.fetch_arrow_table()
df = table.to_pandas(timestamp_as_object=True)
df = pd.DataFrame(
{
name: (
col.to_pylist()
if pat.is_nested(col.type)
else col.to_pandas(timestamp_as_object=True)
)
for name, col in zip(table.column_names, table.columns)
}
)
return schema.apply_to(df)

def _metadata(self, query: str) -> Iterator[tuple[str, dt.DataType]]:
Expand Down
15 changes: 15 additions & 0 deletions ibis/backends/tests/test_generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -791,3 +791,18 @@ def test_bitwise_not_col(backend, alltypes, df):
result = expr.execute()
expected = ~df.int_col
backend.assert_series_equal(result, expected.rename("tmp"))


def test_interactive(alltypes):
expr = alltypes.mutate(
str_col=_.string_col.replace("1", "").nullif("2"),
date_col=_.timestamp_col.date(),
delta_col=lambda t: ibis.now() - t.timestamp_col,
)

orig = ibis.options.interactive
ibis.options.interactive = True
try:
repr(expr)
finally:
ibis.options.interactive = orig
4 changes: 0 additions & 4 deletions ibis/common/grounds.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
from typing import Any
from weakref import WeakValueDictionary

from rich.console import Console

from ibis.common.caching import WeakCache
from ibis.common.validators import (
ImmutableProperty,
Expand All @@ -18,8 +16,6 @@

EMPTY = inspect.Parameter.empty # marker for missing argument

console = Console()


class BaseMeta(ABCMeta):

Expand Down
78 changes: 78 additions & 0 deletions ibis/common/pretty.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
from __future__ import annotations

import datetime
import decimal
from typing import IO

import rich
from rich.console import Console

import ibis
import ibis.common.exceptions as com
import ibis.expr.datatypes as dt
import ibis.expr.types as ir

console = Console()

_IBIS_TO_SQLGLOT_NAME_MAP = {
# not 100% accurate, but very close
"impala": "hive",
Expand Down Expand Up @@ -106,3 +114,73 @@ def to_sql(expr: ir.Expr, dialect: str | None = None) -> str:
pretty=True,
)
return pretty


def _pretty_value(v, dtype: dt.DataType):
if isinstance(v, str):
return v

if isinstance(v, decimal.Decimal):
return f"[bold][light_salmon1]{v}[/][/]"

if isinstance(v, datetime.datetime):
if isinstance(dtype, dt.Date):
return f"[magenta]{v.date()}[/]"

fmt = v.isoformat(timespec='milliseconds').replace("T", " ")
return f"[magenta]{fmt}[/]"

if isinstance(v, datetime.timedelta):
return f"[magenta]{v}[/]"

interactive = ibis.options.repr.interactive
return rich.pretty.Pretty(
v,
max_length=interactive.max_length,
max_string=interactive.max_string,
max_depth=interactive.max_depth,
)


def _format_value(v) -> str:
if v is None:
# render NULL values as the empty set
return "[dim][yellow]∅[/][/]"

if isinstance(v, str):
if not v:
return "[dim][yellow]~[/][/]"

v = (
# replace spaces with dots
v.replace(" ", "[dim]·[/]")
# tab
.replace("\t", r"[dim]\t[/]")
# carriage return
.replace("\r", r"[dim]\r[/]")
# line feed
.replace("\n", r"[dim]\n[/]")
# vertical tab
.replace("\v", r"[dim]\v[/]")
# form feed (page break)
.replace("\f", r"[dim]\f[/]")
)
# display all unprintable characters as a dimmed version of their repr
return "".join(
f"[dim]{repr(c)[1:-1]}[/]" if not c.isprintable() else c for c in v
)
return v


def _format_dtype(dtype):
strtyp = str(dtype)
max_string = ibis.options.repr.interactive.max_string
return (
("[bold][dark_orange]![/][/]" * (not dtype.nullable))
+ "[bold][blue]"
+ (
strtyp[(not dtype.nullable) : max_string]
+ "[orange1]…[/]" * (len(strtyp) > max_string)
)
+ "[/][/]"
)
30 changes: 30 additions & 0 deletions ibis/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,30 @@ class SQL(Config):
default_dialect = rlz.optional(rlz.str_, default="duckdb")


class Interactive(Config):
"""Options controlling the interactive repr.
Attributes
----------
max_rows
Maximum rows to pretty print.
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.
show_types
Show the inferred type of value expressions in the interactive repr.
"""

max_rows = rlz.optional(rlz.int_, default=10)
max_length = rlz.optional(rlz.int_, default=5)
max_string = rlz.optional(rlz.int_, default=80)
max_depth = rlz.optional(rlz.int_, default=2)
show_types = rlz.optional(rlz.bool_, default=True)


class Repr(Config):
"""Expression printing options.
Expand All @@ -82,12 +106,18 @@ class Repr(Config):
SQLQueryResult operations.
show_types : bool
Show the inferred type of value expressions in the repr.
interactive
Options controlling the interactive repr.
"""

depth = rlz.optional(rlz.int_(min=0))
table_columns = rlz.optional(rlz.int_(min=0))
query_text_length = rlz.optional(rlz.int_(min=0), default=80)
show_types = rlz.optional(rlz.bool_, default=False)
interactive = rlz.optional(
rlz.instance_of(Interactive),
default=Interactive(),
)


config_ = rlz.instance_of(Config)
Expand Down
17 changes: 11 additions & 6 deletions ibis/expr/datatypes/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,12 @@ class Bounds(NamedTuple):


@public
class Integer(Primitive):
class Numeric(DataType):
"""Numeric types."""


@public
class Integer(Primitive, Numeric):
"""Integer values."""

scalar = ir.IntegerScalar
Expand Down Expand Up @@ -232,7 +237,7 @@ def bounds(self):


@public
class Floating(Primitive):
class Floating(Primitive, Numeric):
"""Floating point values."""

scalar = ir.FloatingScalar
Expand Down Expand Up @@ -329,7 +334,7 @@ class Float64(Floating):


@public
class Decimal(DataType):
class Decimal(Numeric):
"""Fixed-precision decimal values."""

precision = optional(instance_of(int))
Expand Down Expand Up @@ -386,10 +391,10 @@ def _pretty_piece(self) -> str:
args = []

if (precision := self.precision) is not None:
args.append(f"prec={precision:d}")
args.append(str(precision))

if (scale := self.scale) is not None:
args.append(f"scale={scale:d}")
args.append(str(scale))

if not args:
return ""
Expand Down Expand Up @@ -465,7 +470,7 @@ def resolution(self):

@property
def _pretty_piece(self) -> str:
return f"<{self.value_type}>(unit={self.unit!r})"
return f"({self.unit!r})"


@public
Expand Down
2 changes: 1 addition & 1 deletion ibis/expr/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ def fmt_selection_column(value_expr: object, **_: Any) -> str:

def type_info(datatype: dt.DataType) -> str:
"""Format `datatype` for display next to a column."""
return f" # {datatype}" if ibis.options.repr.show_types else ""
return f" # {datatype}" * ibis.options.repr.show_types


@fmt_selection_column.register
Expand Down
3 changes: 3 additions & 0 deletions ibis/expr/types/analytic.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ def _to_filter(self):
arg = op.arg.to_expr()
return arg == getattr(rank_set, arg.get_name())

def __rich_console__(self, console, options):
return self.to_aggregation().__rich_console__(console, options)

def to_aggregation(
self, metric_name=None, parent_table=None, backup_metric_name=None
):
Expand Down
24 changes: 13 additions & 11 deletions ibis/expr/types/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
TranslationError,
)
from ibis.common.grounds import Immutable
from ibis.common.pretty import console
from ibis.config import _default_backend, options
from ibis.expr.typing import TimeContext
from ibis.util import UnnamedMarker
Expand All @@ -40,17 +41,18 @@ def __repr__(self) -> str:
if not options.interactive:
return self._repr()

try:
result = self.execute()
except TranslationError as e:
lines = [
"Translation to backend failed",
f"Error message: {e.args[0]}",
"Expression repr follows:",
self._repr(),
]
return "\n".join(lines)
return repr(result)
with console.capture() as capture:
try:
console.print(self)
except TranslationError as e:
lines = [
"Translation to backend failed",
f"Error message: {e.args[0]}",
"Expression repr follows:",
self._repr(),
]
return "\n".join(lines)
return capture.get()

def __reduce__(self):
return (self.__class__, (self._arg,))
Expand Down
41 changes: 18 additions & 23 deletions ibis/expr/types/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import ibis.common.exceptions as com
import ibis.expr.datatypes as dt
import ibis.expr.operations as ops
from ibis.common.pretty import _format_value, _pretty_value
from ibis.expr.types.core import Expr, _binop


Expand Down Expand Up @@ -475,52 +476,46 @@ def __le__(self, other: Value) -> ir.BooleanValue:
def __lt__(self, other: Value) -> ir.BooleanValue:
return _binop(ops.Less, self, other)


@public
class Scalar(Value):
def to_projection(self) -> ir.Table:
"""Promote this scalar expression to a projection."""
"""Promote this value expression to a projection."""
from ibis.expr.analysis import find_immediate_parent_tables
from ibis.expr.types.relations import Table

roots = find_immediate_parent_tables(self.op())
if len(roots) > 1:
raise com.RelationError(
'Cannot convert scalar expression '
f'Cannot convert {type(self)} expression '
'involving multiple base table references '
'to a projection'
)

table = Table(roots[0])
return table.projection([self])
return roots[0].to_expr().projection([self])


@public
class Scalar(Value):
def __rich_console__(self, console, options):
return console.render(
_pretty_value(_format_value(self.execute()), self.type()),
options=options,
)

def _repr_html_(self) -> str | None:
return None


@public
class Column(Value):
def to_projection(self) -> ir.Table:
"""Promote this column expression to a projection."""
from ibis.expr.analysis import find_immediate_parent_tables
from ibis.expr.types.relations import Table

roots = find_immediate_parent_tables(self.op())
if len(roots) > 1:
raise com.RelationError(
'Cannot convert array expression involving multiple base '
'table references to a projection'
)

table = Table(roots[0])
return table.projection([self])

def _repr_html_(self) -> str | None:
if not ibis.options.interactive:
return None

return self.execute().to_frame()._repr_html_()

def __rich_console__(self, console, options):
named = self.name(self.op().name)
projection = named.to_projection()
return console.render(projection, options=options)

def bottomk(self, k: int, by: Value | None = None) -> ir.TopK:
raise NotImplementedError("bottomk is not implemented")

Expand Down
Loading

0 comments on commit 04758b8

Please sign in to comment.