diff --git a/ibis/backends/tests/test_client.py b/ibis/backends/tests/test_client.py index 046304147e1f..4ce3b003095b 100644 --- a/ibis/backends/tests/test_client.py +++ b/ibis/backends/tests/test_client.py @@ -756,18 +756,48 @@ def test_dunder_array_column(alltypes, df): np.testing.assert_array_equal(result, expected) -def test_repr_html(alltypes): - t = alltypes.limit(5) +@pytest.mark.parametrize("interactive", [True, False]) +def test_repr(alltypes, interactive): + expr = alltypes.select("id", "int_col") - assert t._repr_html_() is None - assert t.int_col._repr_html_() is None - assert t.int_col.sum()._repr_html_() is None + val = str(alltypes.limit(5).id.execute().iloc[0]) - interactive = ibis.options.interactive - ibis.options.interactive = True + old = ibis.options.interactive + ibis.options.interactive = interactive try: - assert 'table' in t._repr_html_() - assert 'table' in t.int_col._repr_html_() - assert t.int_col.sum()._repr_html_() is None + 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 = interactive + ibis.options.interactive = old + + +@pytest.mark.parametrize("expr_type", ["table", "column"]) +@pytest.mark.parametrize("interactive", [True, False]) +def test_repr_mimebundle(alltypes, interactive, expr_type): + if expr_type == "column": + expr = alltypes.id + else: + expr = alltypes.select("id", "int_col") + + 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 diff --git a/ibis/common/pretty.py b/ibis/common/pretty.py index 5d51c5f7e211..348990a094cd 100644 --- a/ibis/common/pretty.py +++ b/ibis/common/pretty.py @@ -1,19 +1,11 @@ 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", @@ -114,73 +106,3 @@ 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) - ) - + "[/][/]" - ) diff --git a/ibis/config.py b/ibis/config.py index 2ea898e00ca6..6514c3078c19 100644 --- a/ibis/config.py +++ b/ibis/config.py @@ -89,9 +89,9 @@ class Interactive(Config): """ max_rows: int = 10 - max_length: int = 5 + max_length: int = 2 max_string: int = 80 - max_depth: int = 2 + max_depth: int = 1 show_types: bool = True diff --git a/ibis/expr/types/core.py b/ibis/expr/types/core.py index 1a945e6a88cb..f0a7c922bf82 100644 --- a/ibis/expr/types/core.py +++ b/ibis/expr/types/core.py @@ -11,7 +11,6 @@ import ibis.expr.operations as ops from ibis.common.exceptions import IbisError, IbisTypeError, 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 @@ -32,12 +31,14 @@ def __init__(self, arg: ops.Node) -> None: object.__setattr__(self, "_arg", arg) def __repr__(self) -> str: + from ibis.expr.types.pretty import simple_console + if not options.interactive: return self._repr() - with console.capture() as capture: + with simple_console.capture() as capture: try: - console.print(self) + simple_console.print(self) except TranslationError as e: lines = [ "Translation to backend failed", diff --git a/ibis/expr/types/generic.py b/ibis/expr/types/generic.py index c72f7be27a85..273b3f9e4ffc 100644 --- a/ibis/expr/types/generic.py +++ b/ibis/expr/types/generic.py @@ -7,12 +7,12 @@ import ibis.expr.window as win from public import public +from rich.jupyter import JupyterMixin import ibis 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 @@ -500,17 +500,15 @@ def to_projection(self) -> ir.Table: @public class Scalar(Value): def __rich_console__(self, console, options): - return console.render( - _pretty_value(_format_value(self.execute()), self.type()), - options=options, - ) + from rich.text import Text - def _repr_html_(self) -> str | None: - return None + if not ibis.options.interactive: + return console.render(Text(self._repr()), options=options) + return console.render(repr(self.execute()), options=options) @public -class Column(Value): +class Column(Value, JupyterMixin): # Higher than numpy & dask objects __array_priority__ = 20 @@ -519,12 +517,6 @@ class Column(Value): def __array__(self): return self.execute().__array__() - 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() diff --git a/ibis/expr/types/pretty.py b/ibis/expr/types/pretty.py new file mode 100644 index 000000000000..190d8e0d76a0 --- /dev/null +++ b/ibis/expr/types/pretty.py @@ -0,0 +1,333 @@ +import datetime +from functools import singledispatch +from math import isfinite + +import pandas as pd +import rich +from rich.align import Align +from rich.console import Console +from rich.text import Text + +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) + + +@singledispatch +def format_values(dtype, values): + 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, + ) + for v in values + ] + + +@format_values.register(dt.Boolean) +@format_values.register(dt.UUID) +def _(dtype, values): + return [Text(str(v)) for v in values] + + +@format_values.register(dt.Decimal) +def _(dtype, values): + if dtype.scale is not None: + fmt = f"{{:.{dtype.scale}f}}" + return [Text.styled(fmt.format(v), "bold cyan") for v in values] + else: + # No scale specified, convert to float and repr that way + return format_values(dt.float64, [float(v) for v in values]) + + +@format_values.register(dt.Integer) +def _(dtype, values): + return [Text.styled(str(int(v)), "bold cyan") for v in values] + + +@format_values.register(dt.Floating) +def _(dtype, values): + floats = [float(v) for v in values] + # Extract and format all finite floats + finites = [f for f in floats if isfinite(f)] + if all(f == 0 or 1e-6 < abs(f) < 1e6 for f in finites): + strs = [f"{f:f}" for f in finites] + # Trim matching trailing zeros + while all(s.endswith("0") for s in strs): + strs = [s[:-1] for s in strs] + strs = [s + "0" if s.endswith(".") else s for s in strs] + else: + strs = [f"{f:e}" for f in finites] + # Merge together the formatted finite floats with non-finite values + next_f = iter(strs).__next__ + strs2 = [next_f() if isfinite(f) else str(f) for f in floats] + return [Text.styled(s, "bold cyan") for s in strs2] + + +@format_values.register(dt.Timestamp) +def _(dtype, values): + if all(v.microsecond == 0 for v in values): + timespec = "seconds" + elif all(v.microsecond % 1000 == 0 for v in values): + timespec = "milliseconds" + else: + timespec = "microseconds" + return [ + Text.styled(v.isoformat(sep=" ", timespec=timespec), "magenta") + for v in values + ] + + +@format_values.register(dt.Date) +def _(dtype, values): + dates = [ + v.date() if isinstance(v, datetime.datetime) else v for v in values + ] + return [Text.styled(d.isoformat(), "magenta") for d in dates] + + +@format_values.register(dt.Time) +def _(dtype, values): + times = [ + v.time() if isinstance(v, datetime.datetime) else v for v in values + ] + if all(t.microsecond == 0 for t in times): + timespec = "seconds" + elif all(t.microsecond % 1000 == 0 for t in times): + timespec = "milliseconds" + else: + timespec = "microseconds" + return [ + Text.styled(t.isoformat(timespec=timespec), "magenta") for t in times + ] + + +@format_values.register(dt.Interval) +def _(dtype, values): + return [Text.styled(str(v), "magenta") for v in values] + + +_str_escapes = str.maketrans( + { + "\t": r"[dim]\t[/]", + "\r": r"[dim]\r[/]", + "\n": r"[dim]\n[/]", + "\v": r"[dim]\v[/]", + "\f": r"[dim]\f[/]", + } +) + + +@format_values.register(dt.String) +def _(dtype, values): + max_string = ibis.options.repr.interactive.max_string + out = [] + for v in values: + v = str(v) + if v: + if len(v) > max_string: + v = v[: max_string - 1] + "…" + v = v[:max_string] + # Escape all literal `[` so rich doesn't treat them as markup + v = v.replace("[", r"\[") + # Replace ascii escape characters dimmed versions of their repr + v = v.translate(_str_escapes) + if not v.isprintable(): + # display all unprintable characters as a dimmed version of + # their repr + v = "".join( + f"[dim]{repr(c)[1:-1]}[/]" if not c.isprintable() else c + for c in v + ) + text = Text.from_markup(v) + else: + text = Text.styled("~", "dim") + out.append(text) + return out + + +def format_column(dtype, values): + null_str = Text.styled("∅", "dim") + if isinstance(dtype, dt.Floating): + # We don't want to treat `nan` as `NULL` for floating point types + def isnull(x): + return x is None or x is pd.NA + + else: + + def isnull(x): + o = pd.isna(x) + # pd.isna broadcasts if `x` is an array + return o if isinstance(o, bool) else False + + nonnull = [v for v in values if not isnull(v)] + if nonnull: + formatted = format_values(dtype, nonnull) + next_f = iter(formatted).__next__ + out = [null_str if isnull(v) else next_f() for v in values] + else: + out = [null_str] * len(values) + + try: + max_width = max(map(len, out)) + except Exception: + max_width = None + min_width = 20 + else: + if isinstance(dtype, dt.String): + min_width = min(20, max_width) + else: + min_width = max_width + + return out, min_width, max_width + + +def format_dtype(dtype): + max_string = ibis.options.repr.interactive.max_string + strtyp = str(dtype) + if len(strtyp) > max_string: + strtyp = strtyp[: max_string - 1] + "…" + return Text.styled(strtyp, "bold blue") + + +def to_rich_table(table, console_width=None): + if console_width is None: + console_width = float("inf") + + # 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. + columns_truncated = False + if console_width: + computed_cols = [] + remaining = console_width - 1 # 1 char for left boundary + for c in table.columns: + needed = len(c) + 3 # padding + 1 char for right boundary + if needed < remaining: + computed_cols.append(c) + remaining -= needed + else: + table = table.select(*computed_cols) + columns_truncated = True + break + + # Compute the data and return a pandas dataframe + nrows = ibis.options.repr.interactive.max_rows + result = table.limit(nrows + 1).execute() + + # Now format the columns in order, stopping if the console width would + # be exceeded. + col_info = [] + col_data = [] + formatted_dtypes = [] + remaining = console_width - 1 # 1 char for left boundary + for name, dtype in table.schema().items(): + formatted, min_width, max_width = format_column( + dtype, result[name].to_list() + ) + dtype_str = format_dtype(dtype) + if ibis.options.repr.interactive.show_types and not isinstance( + dtype, (dt.Struct, dt.Map, dt.Array) + ): + # Don't truncate non-nested dtypes + min_width = max(min_width, len(dtype_str)) + min_width = max(min_width, len(name)) + if max_width is not None: + max_width = max(min_width, max_width) + needed = min_width + 3 # padding + 1 char for right boundary + if needed < remaining: + col_info.append((name, dtype, min_width, max_width)) + col_data.append(formatted) + formatted_dtypes.append(dtype_str) + remaining -= needed + else: + if remaining < 4: + # Not enough space for ellipsis column, drop previous column + col_info.pop() + col_data.pop() + formatted_dtypes.pop() + columns_truncated = True + break + + # rich's column width computations are super buggy and can result in tables + # that are much wider than the available console space. To work around this + # for now we manually compute all column widths rather than letting rich + # figure it out for us. + 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: + break + if not next_flex_cols: + break + + rich_table = rich.table.Table(padding=(0, 1, 0, 1)) + + # Configure the columns on the rich table. + for name, dtype, _, max_width in col_info: + rich_table.add_column( + Align(name, align="left"), + justify="right" if isinstance(dtype, dt.Numeric) else "left", + vertical="middle", + width=None if max_width is None else col_widths[name], + min_width=None if max_width is not None else col_widths[name], + no_wrap=max_width is not None, + ) + + # If the columns are truncated, add a trailing ellipsis column + if columns_truncated: + rich_table.add_column( + Align("…", align="left"), + justify="left", + vertical="middle", + width=1, + no_wrap=True, + ) + + def add_row(*args, **kwargs): + rich_table.add_row( + *args, Align("[dim]…[/]", align="left"), **kwargs + ) + + else: + add_row = rich_table.add_row + + if formatted_dtypes: + add_row( + *(Align(s, align="left") for s in formatted_dtypes), + end_section=True, + ) + + for row in zip(*col_data): + add_row(*row) + + # If the rows are truncated, add a trailing ellipsis row + if len(result) > nrows: + rich_table.add_row( + *(Align("[dim]…[/]", align=c.justify) for c in rich_table.columns) + ) + + return rich_table diff --git a/ibis/expr/types/relations.py b/ibis/expr/types/relations.py index 89f61840b9cb..b9197bfdf141 100644 --- a/ibis/expr/types/relations.py +++ b/ibis/expr/types/relations.py @@ -19,18 +19,12 @@ import numpy as np from public import public +from rich.jupyter import JupyterMixin import ibis import ibis.common.exceptions as com -import ibis.expr.datatypes as dt import ibis.expr.operations as ops from ibis import util -from ibis.common.pretty import ( - _format_dtype, - _format_value, - _pretty_value, - console, -) from ibis.expr.deferred import Deferred from ibis.expr.types.core import Expr @@ -90,7 +84,7 @@ def f( @public -class Table(Expr): +class Table(Expr, JupyterMixin): # Higher than numpy & dask objects __array_priority__ = 20 @@ -102,97 +96,15 @@ def __array__(self): def __contains__(self, name): return name in self.schema() - def _repr_html_(self) -> str | None: - if not ibis.options.interactive: - return None - - return self.execute()._repr_html_() - def __rich_console__(self, console, options): - import rich.table - from rich.align import Align - from rich.style import Style - - columns_truncated = False - if console.width: - columns = [] - remaining = console.width - 1 # 1 char for left boundary - for c in self.columns: - needed = len(c) + 3 # padding + 1 char for right boundary - if needed < remaining: - columns.append(c) - remaining -= needed - else: - columns_truncated = True - - if columns_truncated: - expr = self.select(*columns) - else: - expr = self - - schema = expr.schema() - types = schema.types - nrows = ibis.options.repr.interactive.max_rows - result = expr.limit(nrows + 1).execute() - - table = rich.table.Table( - row_styles=( - [Style(bgcolor=None), Style(bgcolor="grey7")] - if any( - isinstance(typ, (dt.Struct, dt.Array, dt.Map)) - for typ in types - ) - else None - ) - ) - - alignment = {} - for column in columns: - if isinstance(schema[column], dt.Numeric): - alignment[column] = justify = "right" - else: - justify = "none" - table.add_column( - Align(column, align="left"), - justify=justify, - vertical="middle", - min_width=len(column), - ) + from rich.text import Text - if columns_truncated: - table.add_column( - Align("…", align="left"), - justify="none", - vertical="middle", - min_width=1, - ) - - def add_row(*args, **kwargs): - table.add_row(*args, "…", **kwargs) - - else: - add_row = table.add_row - - if ibis.options.repr.interactive.show_types: - add_row( - *( - Align(_format_dtype(dtype), align="left") - for dtype in types - ), - end_section=True, - ) - - for row in result.iloc[:nrows].itertuples(index=False): - add_row( - *( - _pretty_value(_format_value(v), typ) - for v, typ in zip(row, types) - ) - ) + from ibis.expr.types.pretty import to_rich_table - if len(result) > nrows: - table.add_row(*(Align("…", align="center") for _ in table.columns)) + if not ibis.options.interactive: + return console.render(Text(self._repr()), options=options) + table = to_rich_table(self, options.max_width) return console.render(table, options=options) def __getitem__(self, what): @@ -1058,6 +970,8 @@ def info(self, buf: IO[str] | None = None) -> None: import rich.table from rich.pretty import Pretty + from ibis.expr.types.pretty import console + if buf is None: buf = sys.stdout diff --git a/ibis/tests/expr/test_pretty.py b/ibis/tests/expr/test_pretty.py new file mode 100644 index 000000000000..9a7460806187 --- /dev/null +++ b/ibis/tests/expr/test_pretty.py @@ -0,0 +1,159 @@ +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", "y"], ["int", "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, min_len, max_len = format_column(dt.int64, values) + strs = [str(f) for f in fmts] + assert strs == [null, null, null]