Repository: ibis-project/ibis

Issue:
feat: ux: set max number of columns in repr(Table)
### Is your feature request related to a problem?

When I'm quickly working in a notebook, I often look at a table. But it cuts off the number of columns I see, which I find annoying. I want to be able to see all the columns. in pandas I could do `pandas.options.display.max_columns = int | None`
I would like to be able to do the same thing.

Note that there is also a separate bug that would need to be addressed, where the ibis code that determines how many columns to show uses a while loop that is order O(n), so for very large widths it is slow.

### Describe the solution you'd like

 Currently I monkeypatch in my own method as:

```python
def set_table_repr_width(width: int = 1000):
    """Set the width (in chars) that tables are repr'd at in Jupyter notebooks.

    Often useful to call at the beginning of a notebook.
    """

    # You can update the width later if you want
    ibis.options.repr.max_columns = width

    def my__rich_console__(self, console, options):
        from ibis.expr.types.pretty import to_rich_table
        from rich.text import Text

        if not ibis.options.interactive:
            return console.render(Text(self._repr()), options=options)

        width = ibis.options.repr.max_columns
        table = to_rich_table(self, width)
        options.max_width = width
        return console.render(table, options=options)

    Table.__rich_console__ = my__rich_console__
```

### What version of ibis are you running?

target 5.0?

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

all

### Code of Conduct

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


Discussion:
> But it cuts off the number of columns I see, which I find annoying.

How would this work for a large number of columns on a narrow terminal? Right now we only display as much information as your window has room to display. What kind of display are you hoping to see?

> Note that there is also a separate bug that would need to be addressed, where the ibis code that determines how many columns to show uses a while loop that is order O(n), so for very large widths it is slow.

Do you have a reproducible example that shows the performance issue here?
> Do you have a reproducible example that shows the performance issue here?

Use the above monkeypatch as `set_table_repr_width(width: int = 1_000_000)` and then repr a wide table. Sorry, nothing totally scriptable.

> What kind of display are you hoping to see?

In a .ipynb file in vscode, it just works:

<img width="631" alt="image" src="https://user-images.githubusercontent.com/10820686/217386165-e21dd750-07c4-4d48-a2ba-fdb13cc96546.png">

I can scroll to see the whole frame. Note the scrollbar on the bottom. I'm not sure how terminals really work and how this would actually behave. I think the best you could hope for on a "dumb" terminal is for the long lines to just get wrapped, and it would look bad. This is all pandas does.

At the least, setting `max_columns = None` should just show all columns, and its up to the user to have their terminal configured correctly.

Resolving patch:
diff --git a/ibis/config.py b/ibis/config.py
--- a/ibis/config.py
+++ b/ibis/config.py
@@ -77,6 +77,10 @@ class Interactive(Config):
     ----------
     max_rows : int
         Maximum rows to pretty print.
+    max_columns : int | None
+        The maximum number of columns to pretty print. If 0 (the default), the
+        number of columns will be inferred from output console size. Set to
+        `None` for no limit.
     max_length : int
         Maximum length for pretty-printed arrays and maps.
     max_string : int
@@ -88,6 +92,7 @@ class Interactive(Config):
     """
 
     max_rows: int = 10
+    max_columns: Optional[int] = 0
     max_length: int = 2
     max_string: int = 80
     max_depth: int = 1
diff --git a/ibis/expr/types/pretty.py b/ibis/expr/types/pretty.py
--- a/ibis/expr/types/pretty.py
+++ b/ibis/expr/types/pretty.py
@@ -12,7 +12,6 @@
 import ibis
 import ibis.expr.datatypes as dt
 
-console = Console()
 # A console with all color/markup disabled, used for `__repr__`
 simple_console = Console(force_terminal=False)
 
@@ -197,10 +196,18 @@ def to_rich_table(table, console_width=None):
 
     orig_ncols = len(table.columns)
 
-    # First determine the maximum subset of columns that *might* fit in the
-    # current console. Note that not every column here may actually fit later
-    # on once we know the repr'd width of the data.
-    if console_width:
+    max_columns = ibis.options.repr.interactive.max_columns
+    if console_width == float("inf"):
+        if max_columns == 0:
+            # Show up to 20 columns by default, mirroring pandas's behavior
+            if orig_ncols >= 20:
+                table = table.select(*table.columns[:20])
+        elif max_columns is not None and max_columns < orig_ncols:
+            table = table.select(*table.columns[:max_columns])
+    else:
+        # Determine the maximum subset of columns that *might* fit in the
+        # current console. Note that not every column here may actually fit
+        # later on once we know the repr'd width of the data.
         computed_cols = []
         remaining = console_width - 1  # 1 char for left boundary
         for c in table.columns:
@@ -211,8 +218,12 @@ def to_rich_table(table, console_width=None):
                 computed_cols.append(c)
                 remaining -= needed
             else:
-                table = table.select(*computed_cols)
                 break
+        if max_columns not in (0, None):
+            # If an explicit limit on max columns is set, apply it
+            computed_cols = computed_cols[:max_columns]
+        if orig_ncols > len(computed_cols):
+            table = table.select(*computed_cols)
 
     # Compute the data and return a pandas dataframe
     nrows = ibis.options.repr.interactive.max_rows
@@ -269,29 +280,35 @@ def to_rich_table(table, console_width=None):
     # figure it out for us.
     columns_truncated = orig_ncols > len(col_info)
     col_widths = {}
-    flex_cols = []
-    remaining = console_width - 1
-    if columns_truncated:
-        remaining -= 4
-    for name, _, min_width, max_width in col_info:
-        remaining -= min_width + 3
-        col_widths[name] = min_width
-        if min_width != max_width:
-            flex_cols.append((name, max_width))
-
-    while True:
-        next_flex_cols = []
-        for name, max_width in flex_cols:
-            if remaining:
-                remaining -= 1
-                if max_width is not None:
-                    col_widths[name] += 1
-                if max_width is None or col_widths[name] < max_width:
-                    next_flex_cols.append((name, max_width))
-            else:
+    if console_width == float("inf"):
+        # Always use the max_width if there's infinite console space
+        for name, _, _, max_width in col_info:
+            col_widths[name] = max_width
+    else:
+        # Allocate the remaining space evenly between the flexible columns
+        flex_cols = []
+        remaining = console_width - 1
+        if columns_truncated:
+            remaining -= 4
+        for name, _, min_width, max_width in col_info:
+            remaining -= min_width + 3
+            col_widths[name] = min_width
+            if min_width != max_width:
+                flex_cols.append((name, max_width))
+
+        while True:
+            next_flex_cols = []
+            for name, max_width in flex_cols:
+                if remaining:
+                    remaining -= 1
+                    if max_width is not None:
+                        col_widths[name] += 1
+                    if max_width is None or col_widths[name] < max_width:
+                        next_flex_cols.append((name, max_width))
+                else:
+                    break
+            if not next_flex_cols:
                 break
-        if not next_flex_cols:
-            break
 
     rich_table = rich.table.Table(padding=(0, 1, 0, 1))
 
@@ -313,6 +330,7 @@ def to_rich_table(table, console_width=None):
             justify="left",
             vertical="middle",
             width=1,
+            min_width=1,
             no_wrap=True,
         )
 
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
@@ -126,7 +126,19 @@ def __rich_console__(self, console, options):
         if not ibis.options.interactive:
             return console.render(Text(self._repr()), options=options)
 
-        table = to_rich_table(self, options.max_width)
+        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_table`, as this setting
+            # also needs to be forwarded to `console.render`.
+            options = options.update(max_width=1_000_000)
+            width = None
+        else:
+            width = options.max_width
+
+        table = to_rich_table(self, width)
         return console.render(table, options=options)
 
     def __getitem__(self, what):
@@ -1026,11 +1038,10 @@ def info(self, buf: IO[str] | None = None) -> None:
         buf
             A writable buffer, defaults to stdout
         """
+        import rich
         import rich.table
         from rich.pretty import Pretty
 
-        from ibis.expr.types.pretty import console
-
         if buf is None:
             buf = sys.stdout
 
@@ -1059,6 +1070,7 @@ def info(self, buf: IO[str] | None = None) -> None:
                 f"{100 * (1.0 - non_nulls / n):>3.2f}",
             )
 
+        console = rich.get_console()
         with console.capture() as capture:
             console.print(table)
         buf.write(capture.get())


Test patch:
diff --git a/ibis/backends/tests/test_client.py b/ibis/backends/tests/test_client.py
--- a/ibis/backends/tests/test_client.py
+++ b/ibis/backends/tests/test_client.py
@@ -9,6 +9,7 @@
 import pandas as pd
 import pandas.testing as tm
 import pytest
+import rich.console
 import sqlalchemy as sa
 from pytest import mark, param
 
@@ -839,45 +840,80 @@ def test_dunder_array_column(alltypes, dtype):
 
 
 @pytest.mark.parametrize("interactive", [True, False])
-def test_repr(alltypes, interactive):
+def test_repr(alltypes, interactive, monkeypatch):
+    monkeypatch.setattr(ibis.options, "interactive", interactive)
+
     expr = alltypes.select("id", "int_col")
 
     val = str(alltypes.limit(5).id.execute().iloc[0])
 
-    old = ibis.options.interactive
-    ibis.options.interactive = interactive
-    try:
-        s = repr(expr)
-        # no control characters
-        assert all(c.isprintable() or c in "\n\r\t" for c in s)
-        assert "id" in s
-        if interactive:
-            assert val in s
-        else:
-            assert val not in s
-    finally:
-        ibis.options.interactive = old
+    s = repr(expr)
+    # no control characters
+    assert all(c.isprintable() or c in "\n\r\t" for c in s)
+    assert "id" in s
+    if interactive:
+        assert val in s
+    else:
+        assert val not in s
 
 
 @pytest.mark.parametrize("show_types", [True, False])
-def test_interactive_repr_show_types(alltypes, show_types):
+def test_interactive_repr_show_types(alltypes, show_types, monkeypatch):
+    monkeypatch.setattr(ibis.options, "interactive", True)
+    monkeypatch.setattr(ibis.options.repr.interactive, "show_types", show_types)
+
     expr = alltypes.select("id")
-    old = ibis.options.interactive
-    ibis.options.interactive = True
-    ibis.options.repr.interactive.show_types = show_types
-    try:
-        s = repr(expr)
-        if show_types:
-            assert "int" in s
-        else:
-            assert "int" not in s
-    finally:
-        ibis.options.interactive = old
+    s = repr(expr)
+    if show_types:
+        assert "int" in s
+    else:
+        assert "int" not in s
+
+
+@pytest.mark.parametrize("is_jupyter", [True, False])
+def test_interactive_repr_max_columns(alltypes, is_jupyter, monkeypatch):
+    monkeypatch.setattr(ibis.options, "interactive", True)
+
+    cols = {f"c_{i}": ibis._.id + i for i in range(50)}
+    expr = alltypes.mutate(**cols).select(*cols)
+
+    console = rich.console.Console(force_jupyter=is_jupyter)
+    console.options.max_width = 80
+    options = console.options.copy()
+
+    # max_columns = 0
+    text = "".join(s.text for s in console.render(expr, options))
+    assert " c_0 " in text
+    if is_jupyter:
+        # Default of 20 columns are written
+        assert " c_19 " in text
+    else:
+        # width calculations truncates well before 20 columns
+        assert " c_19 " not in text
+
+    # max_columns = 3
+    monkeypatch.setattr(ibis.options.repr.interactive, "max_columns", 3)
+    text = "".join(s.text for s in console.render(expr, options))
+    assert " c_2 " in text
+    assert " c_3 " not in text
+
+    # max_columns = None
+    monkeypatch.setattr(ibis.options.repr.interactive, "max_columns", None)
+    text = "".join(s.text for s in console.render(expr, options))
+    assert " c_0 " in text
+    if is_jupyter:
+        # All columns written
+        assert " c_49 " in text
+    else:
+        # width calculations still truncates
+        assert " c_19 " not in text
 
 
 @pytest.mark.parametrize("expr_type", ["table", "column"])
 @pytest.mark.parametrize("interactive", [True, False])
-def test_repr_mimebundle(alltypes, interactive, expr_type):
+def test_repr_mimebundle(alltypes, interactive, expr_type, monkeypatch):
+    monkeypatch.setattr(ibis.options, "interactive", interactive)
+
     if expr_type == "column":
         expr = alltypes.id
     else:
@@ -885,18 +921,13 @@ def test_repr_mimebundle(alltypes, interactive, expr_type):
 
     val = str(alltypes.limit(5).id.execute().iloc[0])
 
-    old = ibis.options.interactive
-    ibis.options.interactive = interactive
-    try:
-        reprs = expr._repr_mimebundle_(include=["text/plain", "text/html"], exclude=[])
-        for format in ["text/plain", "text/html"]:
-            assert "id" in reprs[format]
-            if interactive:
-                assert val in reprs[format]
-            else:
-                assert val not in reprs[format]
-    finally:
-        ibis.options.interactive = old
+    reprs = expr._repr_mimebundle_(include=["text/plain", "text/html"], exclude=[])
+    for format in ["text/plain", "text/html"]:
+        assert "id" in reprs[format]
+        if interactive:
+            assert val in reprs[format]
+        else:
+            assert val not in reprs[format]
 
 
 @pytest.mark.never(
diff --git a/ibis/tests/expr/test_pretty_repr.py b/ibis/tests/expr/test_pretty_repr.py
new file mode 100644
--- /dev/null
+++ b/ibis/tests/expr/test_pretty_repr.py
@@ -0,0 +1,166 @@
+import datetime
+import decimal
+
+import pandas as pd
+import pytest
+
+import ibis
+import ibis.expr.datatypes as dt
+from ibis.expr.types.pretty import format_column
+
+null = "∅"
+
+
+def test_format_int_column():
+    values = [None, 1, 2, 3, None]
+    fmts, min_len, max_len = format_column(dt.int64, values)
+    strs = [str(f) for f in fmts]
+    assert strs == [null, "1", "2", "3", null]
+    assert min_len == 1
+    assert max_len == 1
+
+
+def test_format_bool_column():
+    values = [None, True, False]
+    fmts, _, _ = format_column(dt.bool, values)
+    strs = [str(f) for f in fmts]
+    assert strs == [null, "True", "False"]
+
+
+def test_format_float_column():
+    values = [float("nan"), 1.52, -2.0, 0.0, float("inf"), float("-inf"), None]
+    fmts, _, _ = format_column(dt.float64, values)
+    strs = [str(f) for f in fmts]
+    # matching trailing zeros are stripped
+    assert strs == ["nan", "1.52", "-2.00", "0.00", "inf", "-inf", null]
+
+
+def test_format_big_float_column():
+    values = [1.52e6, -2.0e-6]
+    fmts, _, _ = format_column(dt.float64, values)
+    strs = [str(f) for f in fmts]
+    assert strs == ["1.520000e+06", "-2.000000e-06"]
+
+
+@pytest.mark.parametrize("is_float", [False, True])
+def test_format_decimal(is_float):
+    values = [decimal.Decimal("1.500"), decimal.Decimal("2.510")]
+    if is_float:
+        values = [float(v) for v in values]
+
+    # With a scale, render using specified scale, even if backend
+    # doesn't return results as a `Decimal` object.
+    fmts, _, _ = format_column(dt.Decimal(scale=3), values)
+    strs = [str(f) for f in fmts]
+    assert strs == ["1.500", "2.510"]
+
+    # Without scale, decimals render same as floats
+    fmts, _, _ = format_column(dt.Decimal(scale=None), values)
+    strs = [str(f) for f in fmts]
+    assert strs == ["1.50", "2.51"]
+
+
+@pytest.mark.parametrize(
+    "t, prec",
+    [
+        ("2022-02-02 01:02:03", "seconds"),
+        ("2022-02-02 01:02:03.123", "milliseconds"),
+        ("2022-02-02 01:02:03.123456", "microseconds"),
+    ],
+)
+def test_format_timestamp_column(t, prec):
+    a = pd.Timestamp("2022-02-02")
+    b = pd.Timestamp(t)
+    NaT = pd.Timestamp("NaT")
+    fmts, _, _ = format_column(dt.timestamp, [a, b, NaT])
+    strs = [str(f) for f in fmts]
+    assert strs == [
+        a.isoformat(sep=" ", timespec=prec),
+        b.isoformat(sep=" ", timespec=prec),
+        null,
+    ]
+
+
+@pytest.mark.parametrize(
+    "t, prec",
+    [
+        ("01:02:03", "seconds"),
+        ("01:02:03.123", "milliseconds"),
+        ("01:02:03.123456", "microseconds"),
+    ],
+)
+def test_format_time_column(t, prec):
+    a = datetime.time(4, 5, 6)
+    b = datetime.time.fromisoformat(t)
+    NaT = pd.Timestamp("NaT")
+    fmts, _, _ = format_column(dt.time, [a, b, NaT])
+    strs = [str(f) for f in fmts]
+    assert strs == [
+        a.isoformat(timespec=prec),
+        b.isoformat(timespec=prec),
+        null,
+    ]
+
+
+@pytest.mark.parametrize("is_date", [True, False])
+def test_format_date_column(is_date):
+    cls = datetime.date if is_date else datetime.datetime
+    values = [cls(2022, 1, 1), cls(2022, 1, 2)]
+    fmts, _, _ = format_column(dt.date, values)
+    strs = [str(f) for f in fmts]
+    assert strs == ["2022-01-01", "2022-01-02"]
+
+
+def test_format_interval_column():
+    values = [datetime.timedelta(seconds=1)]
+    fmts, _, _ = format_column(dt.interval, values)
+    strs = [str(f) for f in fmts]
+    assert strs == [str(v) for v in values]
+
+
+def test_format_string_column():
+    max_string = ibis.options.repr.interactive.max_string
+    values = [None, "", "test\t\r\n\v\f", "a string", "x" * (max_string + 10)]
+    fmts, min_len, max_len = format_column(dt.string, values)
+    strs = [str(f) for f in fmts]
+    assert strs == [
+        null,
+        "~",
+        "test\\t\\r\\n\\v\\f",
+        "a string",
+        "x" * (max_string - 1) + "…",
+    ]
+    assert min_len == 20
+    assert max_len == max(map(len, strs))
+
+
+def test_format_short_string_column():
+    values = [None, "", "ab", "cd"]
+    fmts, min_len, max_len = format_column(dt.string, values)
+    strs = [str(f) for f in fmts]
+    assert strs == [null, "~", "ab", "cd"]
+    assert min_len == 2
+    assert max_len == 2
+
+
+def test_format_nested_column():
+    dtype = dt.Struct({"x": "int", "y": "float"})
+    values = [{"x": 1, "y": 2.5}, None]
+    fmts, min_len, max_len = format_column(dtype, values)
+    assert str(fmts[1]) == null
+    assert min_len == 20
+    assert max_len is None
+
+
+def test_format_fully_null_column():
+    values = [None, None, None]
+    fmts, *_ = format_column(dt.int64, values)
+    strs = [str(f) for f in fmts]
+    assert strs == [null, null, null]
+
+
+def test_all_empty_groups_repr():
+    values = [float("nan"), float("nan")]
+    dtype = dt.float64
+    fmts, *_ = format_column(dtype, values)
+    assert list(map(str, fmts)) == ["nan", "nan"]
diff --git a/ibis/tests/expr/test_sql.py b/ibis/tests/expr/test_sql.py
--- a/ibis/tests/expr/test_sql.py
+++ b/ibis/tests/expr/test_sql.py
@@ -1,170 +1,6 @@
-import datetime
-import decimal
-
-import pandas as pd
 import pytest
 
 import ibis
-import ibis.expr.datatypes as dt
-from ibis.expr.types.pretty import format_column
-
-null = "∅"
-
-
-def test_format_int_column():
-    values = [None, 1, 2, 3, None]
-    fmts, min_len, max_len = format_column(dt.int64, values)
-    strs = [str(f) for f in fmts]
-    assert strs == [null, "1", "2", "3", null]
-    assert min_len == 1
-    assert max_len == 1
-
-
-def test_format_bool_column():
-    values = [None, True, False]
-    fmts, _, _ = format_column(dt.bool, values)
-    strs = [str(f) for f in fmts]
-    assert strs == [null, "True", "False"]
-
-
-def test_format_float_column():
-    values = [float("nan"), 1.52, -2.0, 0.0, float("inf"), float("-inf"), None]
-    fmts, _, _ = format_column(dt.float64, values)
-    strs = [str(f) for f in fmts]
-    # matching trailing zeros are stripped
-    assert strs == ["nan", "1.52", "-2.00", "0.00", "inf", "-inf", null]
-
-
-def test_format_big_float_column():
-    values = [1.52e6, -2.0e-6]
-    fmts, _, _ = format_column(dt.float64, values)
-    strs = [str(f) for f in fmts]
-    assert strs == ["1.520000e+06", "-2.000000e-06"]
-
-
-@pytest.mark.parametrize("is_float", [False, True])
-def test_format_decimal(is_float):
-    values = [decimal.Decimal("1.500"), decimal.Decimal("2.510")]
-    if is_float:
-        values = [float(v) for v in values]
-
-    # With a scale, render using specified scale, even if backend
-    # doesn't return results as a `Decimal` object.
-    fmts, _, _ = format_column(dt.Decimal(scale=3), values)
-    strs = [str(f) for f in fmts]
-    assert strs == ["1.500", "2.510"]
-
-    # Without scale, decimals render same as floats
-    fmts, _, _ = format_column(dt.Decimal(scale=None), values)
-    strs = [str(f) for f in fmts]
-    assert strs == ["1.50", "2.51"]
-
-
-@pytest.mark.parametrize(
-    "t, prec",
-    [
-        ("2022-02-02 01:02:03", "seconds"),
-        ("2022-02-02 01:02:03.123", "milliseconds"),
-        ("2022-02-02 01:02:03.123456", "microseconds"),
-    ],
-)
-def test_format_timestamp_column(t, prec):
-    a = pd.Timestamp("2022-02-02")
-    b = pd.Timestamp(t)
-    NaT = pd.Timestamp("NaT")
-    fmts, _, _ = format_column(dt.timestamp, [a, b, NaT])
-    strs = [str(f) for f in fmts]
-    assert strs == [
-        a.isoformat(sep=" ", timespec=prec),
-        b.isoformat(sep=" ", timespec=prec),
-        null,
-    ]
-
-
-@pytest.mark.parametrize(
-    "t, prec",
-    [
-        ("01:02:03", "seconds"),
-        ("01:02:03.123", "milliseconds"),
-        ("01:02:03.123456", "microseconds"),
-    ],
-)
-def test_format_time_column(t, prec):
-    a = datetime.time(4, 5, 6)
-    b = datetime.time.fromisoformat(t)
-    NaT = pd.Timestamp("NaT")
-    fmts, _, _ = format_column(dt.time, [a, b, NaT])
-    strs = [str(f) for f in fmts]
-    assert strs == [
-        a.isoformat(timespec=prec),
-        b.isoformat(timespec=prec),
-        null,
-    ]
-
-
-@pytest.mark.parametrize("is_date", [True, False])
-def test_format_date_column(is_date):
-    cls = datetime.date if is_date else datetime.datetime
-    values = [cls(2022, 1, 1), cls(2022, 1, 2)]
-    fmts, _, _ = format_column(dt.date, values)
-    strs = [str(f) for f in fmts]
-    assert strs == ["2022-01-01", "2022-01-02"]
-
-
-def test_format_interval_column():
-    values = [datetime.timedelta(seconds=1)]
-    fmts, _, _ = format_column(dt.interval, values)
-    strs = [str(f) for f in fmts]
-    assert strs == [str(v) for v in values]
-
-
-def test_format_string_column():
-    max_string = ibis.options.repr.interactive.max_string
-    values = [None, "", "test\t\r\n\v\f", "a string", "x" * (max_string + 10)]
-    fmts, min_len, max_len = format_column(dt.string, values)
-    strs = [str(f) for f in fmts]
-    assert strs == [
-        null,
-        "~",
-        "test\\t\\r\\n\\v\\f",
-        "a string",
-        "x" * (max_string - 1) + "…",
-    ]
-    assert min_len == 20
-    assert max_len == max(map(len, strs))
-
-
-def test_format_short_string_column():
-    values = [None, "", "ab", "cd"]
-    fmts, min_len, max_len = format_column(dt.string, values)
-    strs = [str(f) for f in fmts]
-    assert strs == [null, "~", "ab", "cd"]
-    assert min_len == 2
-    assert max_len == 2
-
-
-def test_format_nested_column():
-    dtype = dt.Struct({"x": "int", "y": "float"})
-    values = [{"x": 1, "y": 2.5}, None]
-    fmts, min_len, max_len = format_column(dtype, values)
-    assert str(fmts[1]) == null
-    assert min_len == 20
-    assert max_len is None
-
-
-def test_format_fully_null_column():
-    values = [None, None, None]
-    fmts, *_ = format_column(dt.int64, values)
-    strs = [str(f) for f in fmts]
-    assert strs == [null, null, null]
-
-
-def test_all_empty_groups_repr():
-    values = [float("nan"), float("nan")]
-    dtype = dt.float64
-    fmts, *_ = format_column(dtype, values)
-    assert list(map(str, fmts)) == ["nan", "nan"]
-
 
 catalog = {
     "employee": {"first_name": "string", "last_name": "string", "id": "int64"},