Skip to content

Commit

Permalink
feat(ir): support showing variable names used to create an expression…
Browse files Browse the repository at this point in the history
… in `repr()` (#8630)
  • Loading branch information
kszucs authored Mar 13, 2024
1 parent 461293b commit 220085e
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 48 deletions.
8 changes: 0 additions & 8 deletions ibis/backends/tests/test_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,6 @@ def test_interactive_non_compilable_repr_does_not_fail(table):
repr(table.string_col.topk(3))


def test_histogram_repr_no_query_execute(table, queries):
tier = table.double_col.histogram(10).name("bucket")
expr = table.group_by(tier).size()
expr._repr()

assert not queries


def test_isin_rule_suppressed_exception_repr_not_fail(table):
bool_clause = table["string_col"].notin(["1", "4", "7"])
expr = table[bool_clause]["string_col"].value_counts()
Expand Down
5 changes: 4 additions & 1 deletion ibis/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,19 @@ class Repr(Config):
SQLQueryResult operations.
show_types : bool
Show the inferred type of value expressions in the repr.
show_variables : bool
Show the variables in the repr instead of generated names. This is
an advanced option and may not work in all scenarios.
interactive : bool
Options controlling the interactive repr.
"""

depth: Optional[PosInt] = None
table_columns: Optional[PosInt] = None
table_rows: PosInt = 10
query_text_length: PosInt = 80
show_types: bool = False
show_variables: bool = False
interactive: Interactive = Interactive()


Expand Down
68 changes: 52 additions & 16 deletions ibis/expr/format.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

import functools
import inspect
import itertools
import textwrap
import types
from collections.abc import Mapping, Sequence
from typing import Optional

from public import public

Expand Down Expand Up @@ -145,43 +147,77 @@ def inline_args(fields, prefer_positional=False):
return ", ".join(f"{k}={v}" for k, v in fields.items())


def get_defining_frame(expr):
"""Locate the outermost frame where `expr` is defined."""
for frame_info in inspect.stack()[::-1]:
for var in frame_info.frame.f_locals.values():
if isinstance(var, ir.Expr) and expr.equals(var):
return frame_info.frame
raise ValueError(f"No defining frame found for {expr}")


def get_defining_scope(expr):
"""Get variables in the scope where `expr` is first defined."""
frame = get_defining_frame(expr)
scope = {**frame.f_globals, **frame.f_locals}
return {k: v for k, v in scope.items() if isinstance(v, ir.Expr)}


class Rendered(str):
def __repr__(self):
return self


@public
def pretty(node):
if isinstance(node, ir.Expr):
node = node.op()
elif not isinstance(node, ops.Node):
raise TypeError(f"Expected an expression , got {type(node)}")

def pretty(expr: ir.Expr, scope: Optional[dict[str, ir.Expr]] = None):
"""Pretty print an expression.
Parameters
----------
expr
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.
Returns
-------
str
A pretty printed representation of the expression.
"""
if not isinstance(expr, ir.Expr):
raise TypeError(f"Expected an expression, got {type(expr)}")

node = expr.op()
refs = {}
refcnt = itertools.count()
tables = {}
variables = {v.op(): k for k, v in (scope or {}).items()}

def mapper(op, _, **kwargs):
result = fmt(op, **kwargs)
if isinstance(op, ops.Relation) and not isinstance(op, ops.JoinTable):
tables[op] = result
if var := variables.get(op):
refs[op] = result
result = var
elif isinstance(op, ops.Relation) and not isinstance(op, ops.JoinTable):
refs[op] = result
result = f"r{next(refcnt)}"
return Rendered(result)

results = node.map(mapper)

out = []
for table, rendered in tables.items():
if table is not node:
ref = results[table]
out.append(f"{ref} := {rendered}")
for ref, rendered in refs.items():
if ref is not node:
out.append(f"{results[ref]} := {rendered}")

res = results[node]
res = refs.get(node, results[node])
if isinstance(node, ops.Literal):
out.append(res)
elif isinstance(node, ops.Value):
out.append(f"{node.name}: {res}{type_info(node.dtype)}")
elif isinstance(node, ops.Relation):
out.append(tables[node])
else:
out.append(res)

return "\n\n".join(out)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
alltypes := UnboundTable: alltypes
a int8
b int16
c int32
d int64
e float32
f float64
g string
h boolean
i timestamp
j date
k time

filtered := Filter[alltypes]
alltypes.f > 0

ordered := Sort[filtered]
asc filtered.f

projected := Project[ordered]
a: ordered.a
b: ordered.b
f: ordered.f

add := projected.a + projected.b

sub := projected.a - projected.b

Multiply(Add(a, b), Subtract(a, b)): add * sub
21 changes: 21 additions & 0 deletions ibis/expr/tests/test_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,3 +417,24 @@ def shape(self):

assert "Inc" in result
assert last_line == "incremented: Inc(r0.a)"


def test_format_show_variables(monkeypatch, alltypes, snapshot):
monkeypatch.setattr(ibis.options.repr, "show_variables", True)

filtered = alltypes[alltypes.f > 0]
ordered = filtered.order_by("f")
projected = ordered[["a", "b", "f"]]

add = projected.a + projected.b
sub = projected.a - projected.b
expr = add * sub

result = fmt(expr)

assert "projected.a" in result
assert "projected.b" in result
assert "filtered" in result
assert "ordered" in result

snapshot.assert_match(result, "repr.txt")
48 changes: 25 additions & 23 deletions ibis/expr/types/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def __rich_console__(self, console, options):
if not opts.interactive:
from rich.text import Text

return console.render(Text(self._repr()), options=options)
return console.render(Text(repr(self)), options=options)
return self.__interactive_rich_console__(console, options)

def __interactive_rich_console__(self, console, options):
Expand All @@ -76,35 +76,37 @@ def __coerce__(cls, value):
raise CoercionError("Unable to coerce value to an expression")

def __repr__(self) -> str:
if not opts.interactive:
return self._repr()

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:",
self._repr(),
]
return "\n".join(lines)
return capture.get().rstrip()
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,))

def __hash__(self):
return hash((self.__class__, self._arg))

def _repr(self) -> str:
from ibis.expr.format import pretty

return pretty(self)

def equals(self, other):
"""Return whether this expression is _structurally_ equivalent to `other`.
Expand Down

0 comments on commit 220085e

Please sign in to comment.