Repository: ibis-project/ibis

Issue:
bug: expression repr is converted to a selection in ipython
### What happened?

```py
In [1]: import ibis

In [2]: t = ibis.memtable({"names": ["a", "b"], "values": [1, 2]})

In [3]: t.names
Out[3]:
r0 := InMemoryTable
  data:
    PandasDataFrameProxy:
        names  values
      0     a       1
      1     b       2

Selection[r0]
  selections:
    names: r0.names

In [4]: print(repr(t.names))
r0 := InMemoryTable
  data:
    PandasDataFrameProxy:
        names  values
      0     a       1
      1     b       2

names: r0.names
```

### What version of ibis are you using?

main branch, but tested with `6.1.0` where the issue is present also

### What backend(s) are you using, if any?

_No response_

### Relevant log output

_No response_

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
This is specific to our IPython special method overrides. I suspect it reprs fine in vanilla python.

Resolving patch:
diff --git a/ibis/expr/types/core.py b/ibis/expr/types/core.py
--- a/ibis/expr/types/core.py
+++ b/ibis/expr/types/core.py
@@ -13,7 +13,7 @@
 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, options
+from ibis.config import _default_backend, options as opts
 from ibis.util import experimental
 
 if TYPE_CHECKING:
@@ -46,6 +46,16 @@ class Expr(Immutable, Coercible):
     __slots__ = ("_arg",)
     _arg: ops.Node
 
+    def __rich_console__(self, console, options):
+        if not opts.interactive:
+            from rich.text import Text
+
+            return console.render(Text(self._repr()), options=options)
+        return self.__interactive_rich_console__(console, options)
+
+    def __interactive_rich_console__(self, console, options):
+        raise NotImplementedError()
+
     def __init__(self, arg: ops.Node) -> None:
         object.__setattr__(self, "_arg", arg)
 
@@ -62,7 +72,7 @@ def __coerce__(cls, value):
             raise CoercionError("Unable to coerce value to an expression")
 
     def __repr__(self) -> str:
-        if not options.interactive:
+        if not opts.interactive:
             return self._repr()
 
         from ibis.expr.types.pretty import simple_console
@@ -132,7 +142,7 @@ def get_name(self):
         return self._arg.name
 
     def _repr_png_(self) -> bytes | None:
-        if options.interactive or not options.graphviz_repr:
+        if opts.interactive or not opts.graphviz_repr:
             return None
         try:
             import ibis.expr.visualize as viz
diff --git a/ibis/expr/types/generic.py b/ibis/expr/types/generic.py
--- a/ibis/expr/types/generic.py
+++ b/ibis/expr/types/generic.py
@@ -925,11 +925,7 @@ def to_pandas(self, **kwargs) -> pd.Series:
 
 @public
 class Scalar(Value):
-    def __rich_console__(self, console, options):
-        from rich.text import Text
-
-        if not ibis.options.interactive:
-            return console.render(Text(self._repr()), options=options)
+    def __interactive_rich_console__(self, console, options):
         return console.render(repr(self.execute()), options=options)
 
     def __pyarrow_result__(self, table: pa.Table) -> pa.Scalar:
@@ -995,7 +991,7 @@ def __getitem__(self, _):
     def __array__(self, dtype=None):
         return self.execute().__array__(dtype)
 
-    def __rich_console__(self, console, options):
+    def __interactive_rich_console__(self, console, options):
         named = self.name(self.op().name)
         projection = named.as_table()
         return console.render(projection, options=options)
diff --git a/ibis/expr/types/relations.py b/ibis/expr/types/relations.py
--- a/ibis/expr/types/relations.py
+++ b/ibis/expr/types/relations.py
@@ -295,14 +295,9 @@ def _cast(self, schema: SupportsSchema, cast_method: str = "cast") -> Table:
             cols.append(new_col)
         return self.select(*cols)
 
-    def __rich_console__(self, console, options):
-        from rich.text import Text
-
+    def __interactive_rich_console__(self, console, options):
         from ibis.expr.types.pretty import to_rich_table
 
-        if not ibis.options.interactive:
-            return console.render(Text(self._repr()), options=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


Test patch:
diff --git a/ibis/tests/expr/test_pretty_repr.py b/ibis/tests/expr/test_pretty_repr.py
--- a/ibis/tests/expr/test_pretty_repr.py
+++ b/ibis/tests/expr/test_pretty_repr.py
@@ -5,6 +5,7 @@
 
 import pandas as pd
 import pytest
+from rich.console import Console
 
 import ibis
 import ibis.expr.datatypes as dt
@@ -166,3 +167,14 @@ def test_all_empty_groups_repr():
     dtype = dt.float64
     fmts, *_ = format_column(dtype, values)
     assert list(map(str, fmts)) == ["nan", "nan"]
+
+
+def test_non_interactive_column_repr():
+    t = ibis.table(dict(names="string", values="int"))
+    expr = t.names
+    console = Console()
+    with console.capture() as capture:
+        console.print(expr)
+
+    result = capture.get()
+    assert "Selection" not in result