From 608bb29accaabee166afcf7595fa724772283aed Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Jul 2026 14:34:59 -0500 Subject: [PATCH 01/19] Render rich reprs without pandas, byte-identical to pandas output The rich repr and _repr_html_ previously built a pandas DataFrame and let pandas render it. Rendering is now done in-house: a lightweight numpy-only frame, a ported text formatter (cell formatting with precision and scientific switchover, column widths, max_rows and max_columns truncation, terminal fit, multiline wrap), and an html builder reproducing the notebook table. With pandas absent, users now get the same rich repr they previously needed pandas for; with pandas present, output is byte-identical, and pandas display options that could affect the old output are still honored (max_rows, min_rows, max_columns, width, expand_frame_repr, precision, max_colwidth, chop_threshold, colheader_justify, float_format, html.border, html.use_mathjax). Known non-reproduced corners, all obscure: CJK east-asian width handling; pprint_nest_depth and max_seq_items on huge UDT cells; notebook_repr_html=False, where pandas' _repr_html_ returns None and the old code embedded the literal string "None" in its place, so the new table is a fix rather than a regression; and max_colwidth of 3 or less, where pandas narrows the column labels too and leaves them ragged while the hand renderer keeps them padded to a uniform width. That last case needs a deliberate max_colwidth <= 3 (the default is 50) together with column labels of differing digit counts, and it affects the text repr only, since the html path strips cell padding. The no-pandas tests now assert the pandas-absent output equals the pandas-present output instead of pinning a header-only fallback. Verified against the previous pandas render with a differential that swaps which formatting module graphblas.core resolves to and compares repr() and _repr_html_() end to end: 12376 checks over shapes, density, mask and transpose views, and every dtype including two UDTs; 82320 checks over float and complex cell formatting (precision 0 through 17, the scientific switchover boundaries, chop_threshold, float_format); 1024 checks over expression and infix-expression reprs in both autocompute modes; and 49140 checks over pandas display options crossed with six terminal sizes. The only divergences are the two described above. Rendering is also faster, since pandas' formatter machinery is no longer built per call: repr of a 4x4 Matrix ~850us -> ~90us, of a 10-element Vector ~1.5ms -> ~124us, of a 1e6-element Vector ~9.6ms -> ~1.1ms, and _repr_html_ of a 4x4 Matrix ~373us -> ~53us. These are best-of-5 with both paths interleaved in one process; the ratios (7x to 12x) held across repeated runs on a shared machine, the absolute figures less so. --- graphblas/core/formatting.py | 784 ++++++++++++++++++++++++++--- graphblas/tests/test_formatting.py | 176 +------ 2 files changed, 738 insertions(+), 222 deletions(-) diff --git a/graphblas/core/formatting.py b/graphblas/core/formatting.py index d4ac8cfe4..8098efa32 100644 --- a/graphblas/core/formatting.py +++ b/graphblas/core/formatting.py @@ -1,4 +1,11 @@ -# This file imports pandas, so it should only be imported when formatting +# The rich repr and _repr_html_ are hand-rendered here to reproduce pandas' +# DataFrame text/HTML output byte-for-byte without importing pandas. When pandas +# is installed we still read its display.* options so a user's option_context is +# honored; when it is absent we fall back to pandas' documented defaults. +import math +import re +import shutil + import numpy as np from .. import backend, config, monoid, unary @@ -15,6 +22,35 @@ except ImportError: # pragma: no cover (import) has_pandas = False +# pandas display.* defaults, used verbatim when pandas is not installed so the +# hand renderer produces the same output it would with a freshly imported pandas. +_DISPLAY_DEFAULTS = { + "max_rows": 60, + "min_rows": 10, + "max_columns": 0, + "width": 80, + "expand_frame_repr": True, + "precision": 6, + "max_colwidth": 50, + "chop_threshold": None, + "colheader_justify": "right", + "float_format": None, + "html.border": 1, + "html.use_mathjax": True, +} + + +def _display_option(name): + """Read a pandas display. option, or fall back to its default. + + Reading from pandas keeps a user's ``pd.option_context`` honored while the + rendering logic itself stays pandas-free. + """ + if has_pandas: + return pd.get_option(f"display.{name}") + return _DISPLAY_DEFAULTS[name] + + # This was written by a complete novice at CSS. # If you can help make it better, please do! CSS_STYLE = """ @@ -220,7 +256,7 @@ def _update_vector_array(arr, vector, columns, column_offset, *, mask=None): def _get_max_columns(): - max_columns = pd.options.display.max_columns + max_columns = _display_option("max_columns") if max_columns == 0: # We are probably in a terminal and pandas will automatically size the data correctly. # In this case, let's get a sufficiently large amount of data to show and defer to pandas. @@ -242,13 +278,647 @@ def _get_chunk(length, min_length, max_length): return chunk, chunk_groups +class _Column: + """One rendered column: a label, its raw cell values, and how to format them. + + ``kind`` selects the pandas array formatter this column would have used: + "object" (GenericArrayFormatter), "float" (FloatArrayFormatter, also complex), + or "int" (IntArrayFormatter). ``numeric`` mirrors pandas ``is_numeric_dtype`` + and controls whether the column header gets a leading space. + """ + + __slots__ = ("label", "values", "kind", "numeric") + + def __init__(self, label, values, kind, numeric): + self.label = label + self.values = values + self.kind = kind + self.numeric = numeric + + +class _GBFrame: + """A minimal object/typed-column table, standing in for a pandas DataFrame. + + ``col_name`` is the columns' index name (pandas ``df.columns.name``); when set + it appears in the top-left corner cell, as vector reprs rely on. + """ + + __slots__ = ("columns", "index", "col_name") + + def __init__(self, columns, index, col_name=None): + self.columns = columns + self.index = index + self.col_name = col_name + + @property + def ncols(self): + return len(self.columns) + + @property + def nrows(self): + return len(self.index) + + def slice_cols(self, idx): + return _GBFrame([self.columns[i] for i in idx], self.index, self.col_name) + + def slice_rows(self, idx): + columns = [ + _Column(c.label, [c.values[i] for i in idx], c.kind, c.numeric) for c in self.columns + ] + return _GBFrame(columns, [self.index[i] for i in idx], self.col_name) + + +def _isna_cell(x): + # Gaps in the dense grid are float NaN; present values (including displayed + # "nan"/"inf") are never a bare float NaN, so this only flags the gaps. + return x is None or (isinstance(x, float) and math.isnan(x)) + + +def _count_present(arr): + return sum(1 for x in arr.flat if not _isna_cell(x)) + + +def _dtype_kind_numeric(dtype): + kind = dtype.kind + if kind in "fc": + return "float", True + if kind in "iu": + return "int", True + if kind == "b": + # bool renders via the generic formatter, but is_numeric_dtype(bool) is True + return "object", True + return "object", False + + +def _make_dense_frame(arr, columns, index): + nrows = len(index) + out = [] + for j, label in enumerate(columns): + vals = [("" if _isna_cell(arr[i, j]) else arr[i, j]) for i in range(nrows)] + out.append(_Column(label, vals, "object", False)) + return _GBFrame(out, list(index)) + + +def _make_coo_frame(label_arrays, add_dots): + n = len(label_arrays[0][1]) + index = list(range(n)) + out = [] + for label, values in label_arrays: + values = np.asarray(values) + if add_dots: + out.append(_Column(label, [*values.tolist(), "..."], "object", False)) + else: + kind, numeric = _dtype_kind_numeric(values.dtype) + out.append(_Column(label, values.tolist(), kind, numeric)) + if add_dots: + index.append("...") + return _GBFrame(out, index) + + +# --- cell formatting (reproduces pandas array formatters for object/int/float) --- + +_NUMBER_RE = re.compile(r"^\s*[\+-]?[0-9]+\.[0-9]*$") + + +def _is_float_scalar(v): + # Matches pandas.lib.is_float: python/numpy floats, but not bool/int/complex. + return isinstance(v, (float, np.floating)) + + +def _pprint(v): + # Reproduces pandas printing.pprint_thing for our cell types (escape_chars for + # tab/cr/nl, quote_strings=False): scalars -> str, sequences recurse. + if isinstance(v, (list, tuple)): + body = ", ".join(_pprint(e) for e in v) + if isinstance(v, tuple) and len(v) == 1: + body += "," + return f"[{body}]" if isinstance(v, list) else f"({body})" + s = str(v) + return s.replace("\t", r"\t").replace("\r", r"\r").replace("\n", r"\n") + + +def _trim_zeros_single_float(s): + s = s.rstrip("0") + if s.endswith("."): + s += "0" + return s + + +def _trim_zeros_float(str_floats): + trimmed = list(str_floats) + + def is_number_with_decimal(x): + return _NUMBER_RE.match(x) is not None + + def should_trim(values): + numbers = [x for x in values if is_number_with_decimal(x)] + return len(numbers) > 0 and all(x.endswith("0") for x in numbers) + + while should_trim(trimmed): + trimmed = [x[:-1] if is_number_with_decimal(x) else x for x in trimmed] + return [x + "0" if is_number_with_decimal(x) and x.endswith(".") else x for x in trimmed] + + +def _trim_zeros_complex(str_complexes): + real_part, imag_part = [], [] + for x in str_complexes: + trimmed = re.split(r"(?{padded_length}}" + "j" + for real_pt, imag_pt in zip(padded_parts[:n], padded_parts[n:], strict=True) + ] + + +def _value_formatter(fmt_str, threshold): + def base(v): + return fmt_str.format(value=v) + + if threshold is None: + return base + + def formatter(v): + return base(v) if abs(v) > threshold else base(0.0) + + return formatter + + +def _format_reals_with_na(values, formatter, na_rep): + return [na_rep if (v != v) else formatter(v) for v in values] + + +def _format_complex_with_na(values, formatter, na_rep): + out = [] + for val in values: + re_v, im_v = val.real, val.imag + re_na, im_na = re_v != re_v, im_v != im_v + if not re_na and not im_na: + out.append(formatter(val)) + elif not re_na: + out.append(f"{formatter(re_v)}+{na_rep}j") + elif not im_na: + imag_formatted = formatter(im_v).strip() + if imag_formatted.startswith("-"): + out.append(f"{na_rep}{imag_formatted}j") + else: + out.append(f"{na_rep}+{imag_formatted}j") + else: + out.append(f"{na_rep}+{na_rep}j") + return out + + +def _format_float_column(values, digits): + # Reproduces FloatArrayFormatter for fixed_width, leading_space=True, na_rep="NaN". + arr = np.asarray(values) + is_complex = np.iscomplexobj(arr) + na_rep = "NaN" + if (float_format := _display_option("float_format")) is not None: + # A user display.float_format callable makes FloatArrayFormatter drop + # fixed_width: each value (real or complex) is just float_format(value), + # with no trailing-zero trim and no scientific switchover. Iterate the + # numpy array (not .tolist()) so the callable receives numpy scalars, as + # pandas does; e.g. a "%f"-style callable then casts complex the same way. + return [na_rep if (v != v) else float_format(v) for v in arr] + seq = arr.tolist() + threshold = _display_option("chop_threshold") + + def format_with(fmt_str): + formatter = _value_formatter(fmt_str, threshold) + if is_complex: + return _trim_zeros_complex(_format_complex_with_na(seq, formatter, na_rep)) + return _trim_zeros_float(_format_reals_with_na(seq, formatter, na_rep)) + + result = format_with(f"{{value: .{digits:d}f}}") + too_long = bool(result) and max(len(x) for x in result) > digits + 6 + abs_vals = np.abs(arr) + has_large = bool((abs_vals > 1e6).any()) + has_small = bool(((abs_vals < 10.0 ** (-digits)) & (abs_vals > 0)).any()) + if has_small or (too_long and has_large): + result = format_with(f"{{value: .{digits:d}e}}") + return list(result) + + +def _justify(strings, width, mode="right"): + if mode == "left": + return [x.ljust(width) for x in strings] + if mode == "center": + return [x.center(width) for x in strings] + return [x.rjust(width) for x in strings] + + +def _make_fixed_width(strings, justify="right", minimum=None): + if not strings: + return list(strings) + max_len = max(len(x) for x in strings) + if minimum is not None: + max_len = max(minimum, max_len) + conf_max = _display_option("max_colwidth") + if conf_max is not None and max_len > conf_max: + max_len = conf_max + + def just(x): + if conf_max is not None and conf_max > 3 and len(x) > max_len: + x = x[: max_len - 3] + "..." + return x + + return _justify([just(x) for x in strings], max_len, justify) + + +def _format_labels(labels): + # Reproduce pandas Index._format_flat(include_name=False) for our label types: + # integer labels are padded to a uniform width (left-justified, with a sign + # column when any are negative); string labels are left as-is. + if labels and all(isinstance(x, (int, np.integer)) and not isinstance(x, bool) for x in labels): + pattern = "{: d}" if any(x < 0 for x in labels) else "{:d}" + strs = [pattern.format(x) for x in labels] + width = max(len(s) for s in strs) + return [s.ljust(width) for s in strs] + return [str(x) for x in labels] + + +def _adjoin(space, lists): + # Port of pandas printing.adjoin (ascii len/ljust); glues columns with `space`. + lengths = [max(map(len, x)) + space for x in lists[:-1]] + lengths.append(max(map(len, lists[-1]))) + max_len = max(map(len, lists)) + padded = [] + for i, lst in enumerate(lists): + nl = [x.ljust(lengths[i]) for x in lst] + nl = [" " * lengths[i]] * (max_len - len(lst)) + nl + padded.append(nl) + return "\n".join("".join(parts) for parts in zip(*padded, strict=True)) + + +def _binify(cols, line_width): + adjoin_width = 1 + bins = [] + curr_width = 0 + i_last = len(cols) - 1 + for i, w in enumerate(cols): + w_adjoined = w + adjoin_width + curr_width += w_adjoined + if i_last == i: + wrap = curr_width + 1 > line_width and i > 0 + else: + wrap = curr_width + 2 > line_width and i > 0 + if wrap: + bins.append(i) + curr_width = w_adjoined + bins.append(len(cols)) + return bins + + +def _console_width(): + # pandas repr sets the wrap width from console.get_console_size(); reuse it + # when present so the wrap decision is identical. Without pandas (or if that + # private module moved) fall back to display.width. + if has_pandas: + try: + from pandas.io.formats.console import get_console_size + + return get_console_size()[0] + except Exception: # pragma: no cover (defensive across pandas versions) + pass + return _display_option("width") + + +class _TextFormatter: + """Reproduces pandas DataFrameFormatter + StringFormatter for text repr.""" + + def __init__(self, frame, max_rows, min_rows, max_cols): + self.frame = frame + self.max_rows = max_rows + self.min_rows = min_rows + self.max_cols = max_cols + self.justify = _display_option("colheader_justify") + self.tr_frame = frame + self.tr_col_num = None + self.tr_row_num = None + self.max_cols_fitted = self._calc_max_cols_fitted() + self.max_rows_fitted = self._calc_max_rows_fitted() + self.truncate() + + def _is_in_terminal(self): + return self.max_cols == 0 or self.max_rows == 0 + + def _calc_max_cols_fitted(self): + if not self._is_in_terminal(): + return self.max_cols + width = shutil.get_terminal_size()[0] + if self.max_cols == 0 and self.frame.ncols > width: + return width + return self.max_cols + + def _calc_max_rows_fitted(self): + if self._is_in_terminal() and self.max_rows == 0: + # rows available for data: terminal height minus dots + prompt + header + return shutil.get_terminal_size()[1] - 3 + max_rows = self.max_rows + if max_rows and self.frame.nrows > max_rows and self.min_rows: + max_rows = min(self.min_rows, max_rows) + return max_rows + + @property + def is_truncated_horizontally(self): + return bool(self.max_cols_fitted and self.frame.ncols > self.max_cols_fitted) + + @property + def is_truncated_vertically(self): + return bool(self.max_rows_fitted and self.frame.nrows > self.max_rows_fitted) + + @property + def is_truncated(self): + return self.is_truncated_horizontally or self.is_truncated_vertically + + def truncate(self): + if self.is_truncated_horizontally: + self._truncate_horizontally() + if self.is_truncated_vertically: + self._truncate_vertically() + + def _truncate_horizontally(self): + col_num = self.max_cols_fitted // 2 + if col_num >= 1: + _len = self.tr_frame.ncols + self.tr_frame = self.tr_frame.slice_cols( + [*range(col_num), *range(_len - col_num, _len)] + ) + else: + col_num = self.max_cols + self.tr_frame = self.tr_frame.slice_cols(list(range(col_num))) + self.tr_col_num = col_num + + def _truncate_vertically(self): + row_num = self.max_rows_fitted // 2 + if row_num >= 1: + _len = self.tr_frame.nrows + self.tr_frame = self.tr_frame.slice_rows( + [*range(row_num), *range(_len - row_num, _len)] + ) + else: + row_num = self.max_rows + self.tr_frame = self.tr_frame.slice_rows(list(range(row_num))) + self.tr_row_num = row_num + + def _format_col_raw(self, col): + if col.kind == "int": + return [f"{x: d}" for x in col.values] + if col.kind == "float": + return _format_float_column(col.values, _display_option("precision")) + precision = _display_option("precision") + float_format = _display_option("float_format") + out = [] + for v in col.values: + # A float NaN is excluded from pandas' float-format branch (it uses + # is_float(v) & notna(v)) and rendered as the na_rep "NaN" instead. + if _is_float_scalar(v) and not math.isnan(v): + if float_format is not None: + # A user display.float_format callable replaces the default + # precision render (and adds no sign-space of its own). + out.append(float_format(v)) + else: + out.append(_trim_zeros_single_float(f"{v: .{precision}f}")) + elif v is None: + out.append(" None") + elif _is_float_scalar(v): + out.append(" NaN") + else: + out.append(f" {_pprint(v)}") + return out + + def _get_body_strcols(self): + # Column labels are formatted together (integer labels padded to a uniform + # width) the way pandas Index._format_flat does, not per column. + labels = _format_labels([col.label for col in self.tr_frame.columns]) + strcols = [] + for col, label in zip(self.tr_frame.columns, labels, strict=True): + header = f" {label}" if col.numeric else label + header_colwidth = len(header) + # pandas fixes width twice: format_array right-justifies to the cell + # content width, then the body pass re-justifies with colheader_justify + # (which only matters when the header is wider, or when it is "left"). + fmt_values = _make_fixed_width(self._format_col_raw(col), "right") + fmt_values = _make_fixed_width(fmt_values, self.justify, minimum=header_colwidth) + max_len = max(max((len(x) for x in fmt_values), default=0), header_colwidth) + cheader = _justify([header], max_len, self.justify) + strcols.append(cheader + fmt_values) + return strcols + + def _get_index_strcol(self): + idx = _make_fixed_width([str(x) for x in self.tr_frame.index], justify="left") + corner = "" if self.frame.col_name is None else str(self.frame.col_name) + return [corner, *idx] + + def get_strcols(self): + strcols = self._get_body_strcols() + strcols.insert(0, self._get_index_strcol()) + return strcols + + @property + def _adjusted_tr_col_num(self): + return self.tr_col_num + 1 # index column is always shown + + def _insert_dot_separators(self, strcols): + index_length = len(self._get_index_strcol()) + if self.is_truncated_horizontally: + strcols.insert(self._adjusted_tr_col_num, [" ..."] * index_length) + if self.is_truncated_vertically: + self._insert_dots_vertical(strcols, index_length) + return strcols + + def _insert_dots_vertical(self, strcols, index_length): + n_header_rows = index_length - self.tr_frame.nrows + row_num = self.tr_row_num + for ix, col in enumerate(strcols): + cwidth = len(col[row_num]) + is_dot_col = self.is_truncated_horizontally and ix == self._adjusted_tr_col_num + dots = "..." if (cwidth > 3 or is_dot_col) else ".." + if ix == 0: + dot_mode = "left" + elif is_dot_col: + cwidth = 4 + dot_mode = "right" + else: + dot_mode = "right" + col.insert(row_num + n_header_rows, _justify([dots], cwidth, dot_mode)[0]) + + def _get_strcols(self): + strcols = self.get_strcols() + if self.is_truncated: + strcols = self._insert_dot_separators(strcols) + return strcols + + def _fit_to_terminal(self, strcols): + lines = _adjoin(1, strcols).split("\n") + max_len = max(len(x) for x in lines) + width = shutil.get_terminal_size()[0] + adj_dif = max_len - width + 1 # +1 to avoid too-wide repr (pandas GH #17023) + col_lens = [max((len(x) for x in col), default=0) for col in strcols] + n_cols = len(col_lens) + while adj_dif > 0 and n_cols > 1: + mid = round(n_cols / 2) + adj_dif -= col_lens.pop(mid) + 1 + n_cols = len(col_lens) + max_cols_fitted = max(n_cols - 1, 2) # minus index column; show at least two + self.max_cols_fitted = max_cols_fitted + self.truncate() + return _adjoin(1, self._get_strcols()) + + def _join_multiline(self, strcols, line_width): + adjoin_width = 1 + strcols = list(strcols) + idx = strcols.pop(0) + line_width -= max(len(x) for x in idx) + adjoin_width + col_widths = [max((len(x) for x in col), default=0) for col in strcols] + col_bins = _binify(col_widths, line_width) + nbins = len(col_bins) + blocks = [] + start = 0 + for i, end in enumerate(col_bins): + row = strcols[start:end] + row.insert(0, idx) + if nbins > 1: + nrows = len(row[-1]) + if end <= len(strcols) and i < nbins - 1: + row.append([" \\", *[" "] * (nrows - 1)]) + else: + row.append([" "] * nrows) + blocks.append(_adjoin(adjoin_width, row)) + start = end + return "\n\n".join(blocks) + + def to_string(self, line_width): + strcols = self._get_strcols() + if line_width is None: + return _adjoin(1, strcols) + if self.max_cols > 0: + return self._join_multiline(strcols, line_width) + return self._fit_to_terminal(strcols) + + +def _render_text(frame): + max_cols = _display_option("max_columns") + line_width = _console_width() if _display_option("expand_frame_repr") else None + fmt = _TextFormatter(frame, _display_option("max_rows"), _display_option("min_rows"), max_cols) + return fmt.to_string(line_width) + + +# The scoped style block pandas' NotebookFormatter emits ahead of the table. +_HTML_STYLE = ( + "" +) + + +def _html_escape(s): + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + +class _HtmlBuilder: + """Reproduces pandas NotebookFormatter (DataFrame._repr_html_) markup.""" + + indent_delta = 2 + + def __init__(self, fmt): + self.fmt = fmt + self.tr = fmt.tr_frame + self.ncols = self.tr.ncols + self.th = fmt.is_truncated_horizontally + self.tv = fmt.is_truncated_vertically + self.row_levels = 1 # single-level index, always shown + self.elements = [] + + def write(self, s, indent=0): + self.elements.append(" " * indent + s) + + def _cell(self, s, kind, indent): + rs = _html_escape(str(s)).strip().replace(" ", "  ") + self.write(f"<{kind}>{rs}", indent) + + def write_tr(self, line, indent, header=False, align=None, nindex_levels=0): + self.write("" if align is None else f'', indent) + inner = indent + self.indent_delta + for i, s in enumerate(line): + self._cell(s, "th" if (header or i < nindex_levels) else "td", inner) + self.write("", indent) + + def _col_header(self, indent): + row = ["" if self.tr.col_name is None else str(self.tr.col_name)] + row.extend(_format_labels([col.label for col in self.tr.columns])) + if self.th: + row.insert(self.row_levels + self.fmt.tr_col_num, "...") + self.write_tr(row, indent, header=True, align=self.fmt.justify) + + def _body(self, indent): + index_labels = _format_labels(list(self.tr.index)) + col_cells = [ + _make_fixed_width(self.fmt._format_col_raw(col), "right") for col in self.tr.columns + ] + row = [] + for i in range(self.tr.nrows): + if self.tv and i == self.fmt.tr_row_num: + self.write_tr(["..."] * len(row), indent, nindex_levels=self.row_levels) + row = [index_labels[i], *(col_cells[j][i] for j in range(self.ncols))] + if self.th: + row.insert(self.fmt.tr_col_num + self.row_levels, "...") + self.write_tr(row, indent, nindex_levels=self.row_levels) + + def _table(self, indent=0): + classes = "dataframe" + if not _display_option("html.use_mathjax"): + classes = "dataframe tex2jax_ignore mathjax_ignore" + # pandas keeps the attribute for any non-None border, including 0. + border = _display_option("html.border") + border_attr = "" if border is None else f' border="{border}"' + self.write(f'', indent) + self.write("", indent + self.indent_delta) + self._col_header(indent + 2 * self.indent_delta) + self.write("", indent + self.indent_delta) + self.write("", indent + self.indent_delta) + self._body(indent + 2 * self.indent_delta) + self.write("", indent + self.indent_delta) + self.write("", indent) + + def render(self): + self.write("
") + self.write(_HTML_STYLE) + self._table(0) + self.write("
") + return "\n".join(self.elements) + + +def _render_html(frame): + fmt = _TextFormatter( + frame, + _display_option("max_rows"), + _display_option("min_rows"), + _display_option("max_columns"), + ) + return _HtmlBuilder(fmt).render() + + def _get_matrix_dataframe(matrix, max_rows, min_rows, max_columns, *, mask=None): - if not has_pandas: - return if max_rows is None: # pragma: no branch - max_rows = pd.options.display.max_rows + max_rows = _display_option("max_rows") if min_rows is None: # pragma: no branch - min_rows = pd.options.display.min_rows + min_rows = _display_option("min_rows") if max_columns is None: # pragma: no branch max_columns = _get_max_columns() rows, row_groups = _get_chunk(matrix._nrows, min_rows, max_rows) @@ -265,12 +935,12 @@ def _get_matrix_dataframe(matrix, max_rows, min_rows, max_columns, *, mask=None) column_offset, mask=mask, ) - df = pd.DataFrame(arr, columns=columns, index=rows) + present = _count_present(arr) + truncated = (len(rows), len(columns)) != matrix.shape if ( (mask is None or mask.structure) - and df.shape != matrix.shape - and min(matrix._nvals, max_rows if matrix._nvals <= max_rows else min_rows) - > 2 * df.count().sum() + and truncated + and min(matrix._nvals, max_rows if matrix._nvals <= max_rows else min_rows) > 2 * present ): # The data is sparse and it's better to show in COO format. # SS, SuiteSparse-specific: head @@ -284,47 +954,43 @@ def _get_matrix_dataframe(matrix, max_rows, min_rows, max_columns, *, mask=None) vals = np.zeros(vals.size, dtype=np.uint8) else: vals = np.ones(vals.size, dtype=np.uint8) - df = pd.DataFrame({"row": rows, "col": cols, "val": vals}) - if num_rows < matrix._nvals: - df.loc["..."] = ["..."] * 3 - return df - if mask is not None and not mask.structure and df.shape != matrix.shape: + return _make_coo_frame( + [("row", rows), ("col", cols), ("val", vals)], num_rows < matrix._nvals + ) + if mask is not None and not mask.structure and truncated: # This performs more calculation and uses more memory than I would prefer. # Perhaps we could use the efficient "constant vector or matrix" trick. nonzero = matrix.apply(unary.one["UINT8"]).new(mask=matrix.V, name="") num_rows = matrix._nvals if matrix._nvals <= max_rows else min_rows - if min(nonzero._nvals, num_rows) > 2 * df.count().sum(): + if min(nonzero._nvals, num_rows) > 2 * present: rows, cols, vals = nonzero.ss.head(num_rows, sort=True) if mask.complement: if not vals.flags.writeable: # pragma: no cover (safety) vals = vals.copy() vals[:] = 0 - df = pd.DataFrame({"row": rows, "col": cols, "val": vals}) - if num_rows < nonzero._nvals: - df.loc["..."] = ["..."] * 3 - return df - return df.where(pd.notna(df), "") + return _make_coo_frame( + [("row", rows), ("col", cols), ("val", vals)], num_rows < nonzero._nvals + ) + return _make_dense_frame(arr, columns, rows) def _get_vector_dataframe(vector, max_rows, min_rows, max_columns, *, mask=None): - if not has_pandas: - return if max_rows is None: # pragma: no branch - max_rows = pd.options.display.max_rows + max_rows = _display_option("max_rows") if min_rows is None: # pragma: no branch - min_rows = pd.options.display.min_rows + min_rows = _display_option("min_rows") if max_columns is None: # pragma: no branch max_columns = _get_max_columns() columns, column_groups = _get_chunk(vector._size, max_columns, max_columns) arr = np.full((1, len(columns)), np.nan, dtype=object) for column_group, column_offset in column_groups: _update_vector_array(arr, vector, column_group, column_offset, mask=mask) - df = pd.DataFrame(arr, columns=columns, index=[""]) + present = _count_present(arr) + truncated = len(columns) != vector._size if ( (mask is None or mask.structure) - and df.size != vector._size - and min(vector._nvals, max_rows if vector._nvals <= max_rows else min_rows) - > 2 * df.count().sum() + and truncated + and min(vector._nvals, max_rows if vector._nvals <= max_rows else min_rows) > 2 * present ): # The data is sparse and it's better to show in COO format. # SS, SuiteSparse-specific: head @@ -335,26 +1001,20 @@ def _get_vector_dataframe(vector, max_rows, min_rows, max_columns, *, mask=None) vals = np.zeros(vals.size, dtype=np.uint8) else: vals = np.ones(vals.size, dtype=np.uint8) - df = pd.DataFrame({"index": indices, "val": vals}) - if num_rows < vector._nvals: - df.loc["..."] = ["..."] * 2 - return df - if mask is not None and not mask.structure and df.size != vector._size: + return _make_coo_frame([("index", indices), ("val", vals)], num_rows < vector._nvals) + if mask is not None and not mask.structure and truncated: # This performs more calculation and uses more memory than I would prefer. # Perhaps we could use the efficient "constant vector or matrix" trick. nonzero = vector.apply(unary.one["UINT8"]).new(mask=vector.V, name="") num_rows = vector._nvals if vector._nvals <= max_rows else min_rows - if min(nonzero._nvals, num_rows) > 2 * df.count().sum(): + if min(nonzero._nvals, num_rows) > 2 * present: indices, vals = nonzero.ss.head(num_rows, sort=True) if mask.complement: if not vals.flags.writeable: # pragma: no cover (safety) vals = vals.copy() vals[:] = 0 - df = pd.DataFrame({"index": indices, "val": vals}) - if num_rows < nonzero._nvals: - df.loc["..."] = ["..."] * 2 - return df - return df.where(pd.notna(df), "") + return _make_coo_frame([("index", indices), ("val", vals)], num_rows < nonzero._nvals) + return _make_dense_frame(arr, columns, [""]) def get_format(x, is_transposed=False): @@ -437,14 +1097,9 @@ def vector_expression_header_html(matrix, expr): return create_header_html(name, keys, vals) -def _format_html(name, header, df, collapse): - if has_pandas: - state = "" if collapse else " open" - with pd.option_context("display.show_dimensions", False, "display.large_repr", "truncate"): - details = df._repr_html_() - else: - state = "" - details = "(Install pandas to see a preview of the data)" +def _format_html(name, header, frame, collapse): + state = "" if collapse else " open" + details = _render_html(frame) return ( "
" f"{CSS_STYLE}" @@ -668,17 +1323,12 @@ def format_matrix(matrix, *, max_rows=None, min_rows=None, max_columns=None, mas name, keys, vals, - lower_border=has_pandas, + lower_border=True, name=matrix.name if mask is None else mask.name, ) - if has_pandas: - df = _get_matrix_dataframe(matrix, max_rows, min_rows, max_columns, mask=mask) - if 0 not in matrix.shape: - with pd.option_context( - "display.show_dimensions", False, "display.large_repr", "truncate" - ): - df_repr = df.__repr__() - return f"{header}\n{df_repr}" + if 0 not in matrix.shape: + frame = _get_matrix_dataframe(matrix, max_rows, min_rows, max_columns, mask=mask) + return f"{header}\n{_render_text(frame)}" return header @@ -688,20 +1338,16 @@ def format_vector(vector, *, max_rows=None, min_rows=None, max_columns=None, mas name, keys, vals, - lower_border=has_pandas, + lower_border=True, name=vector.name if mask is None else mask.name, ) - if has_pandas: - df = _get_vector_dataframe(vector, max_rows, min_rows, max_columns, mask=mask) - if vector._size > 0: - if df.columns[0] != "index": - df.columns.name = "index" - df.index = ["value"] - with pd.option_context( - "display.show_dimensions", False, "display.large_repr", "truncate" - ): - df_repr = df.__repr__() - return f"{header}\n{df_repr}" + if vector._size > 0: + frame = _get_vector_dataframe(vector, max_rows, min_rows, max_columns, mask=mask) + if frame.columns[0].label != "index": + # Dense vectors label the corner "index" and the single row "value". + frame.col_name = "index" + frame.index = ["value"] + return f"{header}\n{_render_text(frame)}" return header diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 75393afa0..4368d8369 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -146,38 +146,24 @@ def t(): def test_no_pandas_repr(A, C, v, w): - # This is a bit of a hack... + # The rich repr is hand-rendered, so it no longer depends on pandas: with + # pandas marked absent the output is byte-identical to the pandas-present + # output, data grid and all. (When pandas is genuinely absent both branches + # use the same pandas-free path, so this still exercises that code.) + objs = [A, A.T, C, C.S, ~C.V, v, v.S, ~w.V, w] + expected = [repr(x) for x in objs] has_pandas_prev = formatting.has_pandas formatting.has_pandas = False try: - repr_printer(A, "A", indent=8) - assert repr(A) == ( - '"A_1" nvals nrows ncols dtype format\n' - "gb.Matrix 3 1 5 INT64 bitmapr" - ) - repr_printer(A.T, "A.T", indent=8) - assert repr(A.T) == ( - '"A_1.T" nvals nrows ncols dtype format\n' - "gb.TransposedMatrix 3 5 1 INT64 bitmapc" - ) - repr_printer(C.S, "C.S", indent=8) - assert repr(C.S) == ( - '"C.S" nvals nrows ncols dtype format\n' - "StructuralMask\n" - "of gb.Matrix 8 70 77 INT64 hypercsr" - ) - repr_printer(v, "v", indent=8) - assert repr(v) == ( - '"v" nvals size dtype format\ngb.Vector 3 5 FP64 bitmap' - ) - repr_printer(~w.V, "~w.V", indent=8) - assert repr(~w.V) == ( - '"~w.V" nvals size dtype format\n' - "ComplementedValueMask\n" - "of gb.Vector 4 77 INT64 bitmap" - ) + actual = [repr(x) for x in objs] finally: formatting.has_pandas = has_pandas_prev + assert actual == expected + # The data grid is rendered, not just the header: the border line and the + # values are present. + lines = repr(A).split("\n") + assert lines[2].startswith("----") + assert lines[-1] == "0 0 1 2" @pytest.mark.skipif("not pd") @@ -517,137 +503,21 @@ def test_scalar_repr(s, t): def test_no_pandas_repr_html(A, C, v, w): - # This is a bit of a hack... + # _repr_html_ is hand-rendered too: marking pandas absent yields output that + # is byte-identical to the pandas-present output, data table included. + objs = [A, A.T, C, C.S, ~C.V, v, v.S, ~w.V, w] + expected = [repr_html(x) for x in objs] has_pandas_prev = formatting.has_pandas formatting.has_pandas = False try: - html_printer(A, "A", indent=8) - assert repr_html(A) == ( - "
" - f"{CSS_STYLE}" - '
A1
\n' - '\n' - " \n" - ' \n' - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - "
gb.Matrix
nvals
nrows
ncols
dtype
format
315INT64bitmapr
\n" - "
\n" - "
(Install pandas to see a preview of the data)
" - ) - html_printer(A.T, "A.T", indent=8) - assert repr_html(A.T) == ( - "
" - f"{CSS_STYLE}" - '
A1.T
\n' - '\n' - " \n" - ' \n' - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - "
gb.TransposedMatrix
nvals
nrows
ncols
dtype
format
351INT64bitmapc
\n" - "
\n" - "
(Install pandas to see a preview of the data)
" - ) - html_printer(C.S, "C.S", indent=8) - assert repr_html(C.S) == ( - "
" - f"{CSS_STYLE}" - '
C.S
\n' - '\n' - " \n" - ' \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - "
StructuralMask\n'
-            "of\n"
-            "gb.Matrix
nvals
nrows
ncols
dtype
format
87077INT64hypercsr
\n" - "
\n" - "
(Install pandas to see a preview of the data)
" - ) - html_printer(v, "v", indent=8) - assert repr_html(v) == ( - "
" - f"{CSS_STYLE}" - '
v
\n' - '\n' - " \n" - ' \n' - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - "
gb.Vector
nvals
size
dtype
format
35FP64bitmap
\n" - "
\n" - "
(Install pandas to see a preview of the data)
" - ) - html_printer(~w.V, "~w.V", indent=8) - assert repr_html(~w.V) == ( - "
" - f"{CSS_STYLE}" - '
~w.V
\n' - '\n' - " \n" - ' \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - " \n" - "
ComplementedValueMask\n'
-            "of\n"
-            "gb.Vector
nvals
size
dtype
format
477INT64bitmap
\n" - "
\n" - "
(Install pandas to see a preview of the data)
" - ) + actual = [repr_html(x) for x in objs] finally: formatting.has_pandas = has_pandas_prev + assert actual == expected + # The data table is rendered, not the "install pandas" placeholder. + html = repr_html(A) + assert "install" not in html.lower() + assert '' in html @pytest.mark.skipif("not pd") From b50fde854032bdb49e7ffa5b3454628c95aa6987 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Jul 2026 14:34:59 -0500 Subject: [PATCH 02/19] Un-gate formatting tests that no longer need pandas The rich repr renders without pandas as of the previous commit, so 30 of the 38 skipif("not pd") gates in test_formatting.py are obsolete; in a pandas-free environment those tests now run instead of skipping (12 -> 42 passing). The 8 tests exercising pd.option_context stay gated, as do the four autocompute tests with their own runtime skip. Several un-gated tests assert the full pandas-style HTML verbatim and pass without pandas, confirming the hand renderer is byte-identical. Verified with pandas installed (unchanged: the removed gates never fired there) and with pandas blocked at sys.meta_path, where test_formatting.py goes from 12 passed / 42 skipped to 42 passed / 12 skipped, with no failures. --- graphblas/tests/test_formatting.py | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 4368d8369..a35325cdc 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -166,7 +166,6 @@ def test_no_pandas_repr(A, C, v, w): assert lines[-1] == "0 0 1 2" -@pytest.mark.skipif("not pd") def test_matrix_repr_small(A, B): repr_printer(A, "A") assert repr(A) == ( @@ -198,7 +197,6 @@ def test_matrix_repr_small(A, B): ) -@pytest.mark.skipif("not pd") def test_matrix_mask_repr_small(A): repr_printer(A.S, "A.S") assert repr(A.S) == ( @@ -390,7 +388,6 @@ def test_matrix_mask_repr_large(C): ) -@pytest.mark.skipif("not pd") def test_vector_repr_small(v): repr_printer(v, "v") assert repr(v) == ( @@ -415,7 +412,6 @@ def test_vector_repr_large(w): ) -@pytest.mark.skipif("not pd") def test_vector_mask_repr_small(v): repr_printer(v.S, "v.S") assert repr(v.S) == ( @@ -520,7 +516,6 @@ def test_no_pandas_repr_html(A, C, v, w): assert '
' in html -@pytest.mark.skipif("not pd") def test_matrix_repr_html_small(A, B): html_printer(A, "A") assert repr_html(A) == ( @@ -715,7 +710,6 @@ def test_matrix_repr_html_small(A, B): ) -@pytest.mark.skipif("not pd") def test_matrix_mask_repr_html_small(A): html_printer(A.S, "A.S") assert repr_html(A.S) == ( @@ -1991,7 +1985,6 @@ def test_matrix_mask_repr_html_large(C): ) -@pytest.mark.skipif("not pd") def test_vector_repr_html_small(v): html_printer(v, "v") assert repr_html(v) == ( @@ -2137,7 +2130,6 @@ def test_vector_repr_html_large(w): ) -@pytest.mark.skipif("not pd") def test_vector_mask_repr_html_small(v): html_printer(v.S, "v.S") assert repr_html(v.S) == ( @@ -2759,7 +2751,6 @@ def test_apply_repr(v): ) -@pytest.mark.skipif("not pd") def test_apply_repr_html(v): html_printer(v.apply(unary.one), "v.apply(unary.one)") assert repr_html(v.apply(unary.one)) == ( @@ -2792,7 +2783,6 @@ def test_mxm_repr(A, B): ) -@pytest.mark.skipif("not pd") def test_mxm_repr_html(A, B): html_printer(A.mxm(B), "A.mxm(B)") assert repr_html(A.mxm(B)) == ( @@ -2827,7 +2817,6 @@ def test_mxv_repr(A, v): ) -@pytest.mark.skipif("not pd") def test_mxv_repr_html(A, v): html_printer(A.mxv(v), "A.mxv(v)") assert repr_html(A.mxv(v)) == ( @@ -2850,7 +2839,6 @@ def test_mxv_repr_html(A, v): ) -@pytest.mark.skipif("not pd") def test_matrix_reduce_columns_repr_html(A): # This is implemented using the transpose of A, so make sure we're oriented correctly! html_printer(A.reduce_columnwise(), "A.reduce_columnwise()") @@ -2884,7 +2872,6 @@ def test_matrix_reduce_repr(C, v): ) -@pytest.mark.skipif("not pd") def test_matrix_reduce_repr_html(C, v): html_printer(C.reduce_scalar(), "C.reduce_scalar()", indent=8) assert repr_html(C.reduce_scalar()) == ( @@ -2905,7 +2892,6 @@ def test_matrix_reduce_repr_html(C, v): ) -@pytest.mark.skipif("not pd") def test_matrix_huge(): M = Matrix(int, nrows=2**60, ncols=2**60, name="M") repr_printer(M, "M") @@ -2931,7 +2917,6 @@ def test_matrix_huge(): assert M.isequal(M2) -@pytest.mark.skipif("not pd") def test_matrix_huge_html(): M = Matrix(int, nrows=2**60, ncols=2**60, name="M") html_printer(M, "M") @@ -3124,7 +3109,6 @@ def test_matrix_huge_html(): ) -@pytest.mark.skipif("not pd") def test_vector_huge(): v = Vector(int, size=2**60) repr_printer(v, "v") @@ -3139,7 +3123,6 @@ def test_vector_huge(): assert v2.isequal(v) -@pytest.mark.skipif("not pd") def test_vector_huge_html(): v = Vector(int, size=2**60) html_printer(v, "v") @@ -3277,7 +3260,6 @@ def test_vector_huge_html(): ) -@pytest.mark.skipif("not pd") def test_sparse_vector_repr(): v = Vector.from_coo([100 * i for i in range(100)], [10 * i for i in range(100)], name="v") repr_printer(v, "v") @@ -3431,7 +3413,6 @@ def test_sparse_vector_repr(): ) -@pytest.mark.skipif("not pd") def test_sparse_matrix_repr(): A = Matrix.from_coo( [100 * i for i in range(100)], [10 * i for i in range(100)], list(range(100)), name="A" @@ -3605,7 +3586,6 @@ def test_sparse_matrix_repr(): ) -@pytest.mark.skipif("not pd") def test_infix_expr_repr_html(A, B, v): html_printer(v & v, "v & v") assert repr_html(v & v) == ( @@ -3804,7 +3784,6 @@ def test_infix_expr_repr_html(A, B, v): ) -@pytest.mark.skipif("not pd") def test_infix_expr_repr(A, B, v): repr_printer(v & v, "v & v") assert repr(v & v) == ( @@ -3880,7 +3859,6 @@ def test_infix_expr_repr(A, B, v): ) -@pytest.mark.skipif("not pd") def test_scalar_infix_expr_repr_literal(): # A literal shows its value and its own type, as it has no dtype until an op types it s = Scalar.from_value(5, dtype=dtypes.INT8, name="s_1") @@ -3938,7 +3916,6 @@ def test_scalar_infix_expr_repr_auto(): assert "\n " in html -@pytest.mark.skipif("not pd") def test_inner_outer_repr_html(v): html_printer(v.inner(v), "v.inner(v)") assert repr_html(v.inner(v)) == ( @@ -3980,7 +3957,6 @@ def test_inner_outer_repr_html(v): ) -@pytest.mark.skipif("not pd") def test_inner_outer_repr(v): # XXX: hmm, having `(GrB_Matrix)` here isn't so pretty repr_printer(v.inner(v), "v.inner(v)") @@ -4406,7 +4382,6 @@ def test_autocompute_html(A, B, v): ) -@pytest.mark.skipif("not pd") def test_display_nan(): v = Vector.from_coo([0, 1], [1.0, np.nan], size=3, name="v") repr_printer(v, "v") @@ -4540,7 +4515,6 @@ def test_display_nan(): ) -@pytest.mark.skipif("not pd") def test_large_iso(): A = Matrix(int, nrows=2**60, ncols=2**60) A[:, :] << 1 @@ -4769,7 +4743,6 @@ def test_index_expr_matrix_html(A): ) -@pytest.mark.skipif("not pd") def test_scalar_as_vector(): s = Scalar.from_value(5, is_cscalar=False) # pragma: is_grbscalar v = s._as_vector() @@ -4883,7 +4856,6 @@ def test_index_expr_autocompute(v): ) -@pytest.mark.skipif("not pd") def test_udt(): record_dtype = np.dtype([("x", np.bool_), ("y", np.int64)], align=True) udt = dtypes.register_anonymous(record_dtype, "record_dtype") @@ -4933,7 +4905,6 @@ def test_udt(): ) -@pytest.mark.skipif("not pd") def test_empty(): v = Vector(int, 0) repr_printer(v, "v") @@ -4958,7 +4929,6 @@ def test_empty(): ) -@pytest.mark.skipif("not pd") def test_vector_as_matrix(): # Built iso outright: the repr asserted below names the format, and # SuiteSparse 10.5.0 no longer infers iso from a build whose values happen From fb7790d7f971cdee1caddf2c4502c1aa4245ae67 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Jul 2026 14:34:59 -0500 Subject: [PATCH 03/19] Un-gate four pandas-free autocompute tests in test_formatting.py Since the pandas-free repr, test_autocompute, test_autocompute_html, test_index_expr_autocompute, and test_scalar_infix_expr_repr_auto only assert repr/_repr_html_ output that renders byte-identically without pandas, so the runtime "if not pd: skip" guards are obsolete. Removed them. The eight large-repr tests that call pd.option_context stay gated (they genuinely need pandas to exercise user display options). Verified with pandas installed (unchanged: the removed guards never fired there) and with pandas blocked at sys.meta_path, where test_formatting.py goes from 42 passed / 12 skipped to 46 passed / 8 skipped; the four moved from skip to pass, with no failures. --- graphblas/tests/test_formatting.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index a35325cdc..52afd77cf 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -3885,8 +3885,6 @@ def test_scalar_infix_expr_repr_literal(): @autocompute def test_scalar_infix_expr_repr_auto(): - if not pd: # pragma: no cover (import) - pytest.skip("needs pandas") # As for Vector, only BOOL `x & y` and `x | y` compute without an op, to show the result s = Scalar.from_value(5, dtype=dtypes.INT8, name="s_1") repr_printer(s & s, "s & s") @@ -3977,8 +3975,6 @@ def test_inner_outer_repr(v): @autocompute def test_autocompute(A, B, v): - if not pd: # pragma: no cover (import) - pytest.skip("needs pandas") repr_printer(A & A, "A & A") assert repr(A & A) == ( "gb.MatrixEwiseMultExpr nrows ncols left_dtype right_dtype\n" @@ -4059,8 +4055,6 @@ def test_autocompute(A, B, v): @autocompute def test_autocompute_html(A, B, v): - if not pd: # pragma: no cover (import) - pytest.skip("needs pandas") html_printer(A & A, "A & A") assert repr_html(A & A) == ( "
" @@ -4801,8 +4795,6 @@ def test_scalar_as_vector(): @autocompute def test_index_expr_autocompute(v): - if not pd: # pragma: no cover (import) - pytest.skip("needs pandas") html_printer(v[[0, 1]], "v[[0, 1]]") assert repr_html(v[[0, 1]]) == ( "
" From 710bf85d214b085dd9b6b3236334557900027b86 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 07:22:53 -0500 Subject: [PATCH 04/19] Test float, complex and bool values in sparse reprs A sparse object whose values all fit in display.max_rows is shown in COO form with a typed value column, and only that path reaches the float and complex formatting ported from pandas: one number of decimals per column, trimmed trailing zeros, NaN and inf, the switch to scientific notation, chop_threshold, and a float_format callable. test_formatting.py pinned only int-valued COO reprs, so none of it ran. Pin text and HTML reprs of float, float32, complex and complex64 values (including each mix of NaN real and imaginary parts), the precision and chop_threshold options, a float_format callable in COO form and in the dense grid, a bool column, and a float column in a COO form cut short by "...". Every expected string is what main's pandas renderer gives for the same input. display_options sets an option in the renderer's own defaults, which it reads when pandas is absent, and also through pandas when pandas is installed, so these tests run with the same expected strings in both environments. --- graphblas/tests/test_formatting.py | 545 +++++++++++++++++++++++++++++ 1 file changed, 545 insertions(+) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 52afd77cf..c81847efb 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -1,3 +1,5 @@ +import contextlib + import numpy as np import pytest @@ -81,6 +83,21 @@ def html_printer(x, name="", indent=4): return _printer(repr_html(x), name, "repr_html", indent) +@contextlib.contextmanager +def display_options(*args): + """Set display options as ``pd.option_context`` does, with or without pandas. + + The renderer reads pandas' display options when pandas is installed and its own + copy of their defaults otherwise. Setting both lets a test expect the same output + in either environment. + """ + with pytest.MonkeyPatch.context() as mp: + for key, val in zip(args[::2], args[1::2], strict=True): + mp.setitem(formatting._DISPLAY_DEFAULTS, key.removeprefix("display."), val) + with pd.option_context(*args) if pd is not None else contextlib.nullcontext(): + yield + + @pytest.fixture(autouse=True) def _stable_repr_width(monkeypatch): """Pin terminal width so pandas-rendered reprs are deterministic. @@ -3586,6 +3603,534 @@ def test_sparse_matrix_repr(): ) +def test_sparse_float_repr(): + # A sparse float object whose nvals fit in display.max_rows is shown in COO form + # with a float column: every value gets the same number of decimals, less the + # trailing zeros they all share, and NaN shows as "NaN" where the dense grid shows + # "nan". + indices = [100, 161, 222, 283, 344, 405, 466] + values = [1.5, -2.25, 0.0, 100.0, np.nan, np.inf, -np.inf] + v = Vector.from_coo(indices, values, size=1000, name="v") + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 7 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 1.50\n" + "1 161 -2.25\n" + "2 222 0.00\n" + "3 283 100.00\n" + "4 344 NaN\n" + "5 405 inf\n" + "6 466 -inf" + ) + html_printer(v, "v") + assert repr_html(v) == ( + "
" + f"{CSS_STYLE}" + '
v
\n' + '
TrueBOOL
\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
gb.Vector
nvals
size
dtype
format
71000FP64sparse
\n" + "
\n" + "
\n" + "\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
indexval
01001.50
1161-2.25
22220.00
3283100.00
4344NaN
5405inf
6466-inf
\n" + "
" + ) + A = Matrix.from_coo( + [3, 104, 205, 306, 407, 508, 609], + [900, 811, 722, 633, 544, 455, 366], + values, + nrows=1000, + ncols=1000, + name="A", + ) + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 7 1000 1000 FP64 hypercsr\n" + "-----------------------------------------------\n" + " row col val\n" + "0 3 900 1.50\n" + "1 104 811 -2.25\n" + "2 205 722 0.00\n" + "3 306 633 100.00\n" + "4 407 544 NaN\n" + "5 508 455 inf\n" + "6 609 366 -inf" + ) + v = Vector.from_coo([100, 161, 222], [1.0, -2.0, 30.0], size=1000, name="v") + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 3 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 1.0\n" + "1 161 -2.0\n" + "2 222 30.0" + ) + v = Vector.from_coo( + [100, 161, 222, 283], [0.1, 1 / 3, -2.5, np.nan], dtype="FP32", size=1000, name="v" + ) + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 4 1000 FP32 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 0.100000\n" + "1 161 0.333333\n" + "2 222 -2.500000\n" + "3 283 NaN" + ) + + +def test_sparse_float_repr_scientific(): + # Scientific notation when a value is smaller than the precision shows, or when + # one is above 1e6 and the fixed-point column would be too wide. + v = Vector.from_coo([100, 161, 222], [1e-8, 2.5, -3.0], size=1000, name="v") + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 3 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 1.000000e-08\n" + "1 161 2.500000e+00\n" + "2 222 -3.000000e+00" + ) + v = Vector.from_coo([100, 161, 222], [1.5e10, 2.0, -0.125], size=1000, name="v") + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 3 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 1.500000e+10\n" + "1 161 2.000000e+00\n" + "2 222 -1.250000e-01" + ) + # Too wide for fixed point but not large: stays fixed point + v = Vector.from_coo([100, 161, 222], [123456.789, 0.5, -7.0], size=1000, name="v") + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 3 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 123456.789\n" + "1 161 0.500\n" + "2 222 -7.000" + ) + v = Vector.from_coo( + [100, 161, 222, 283], [1e-3, 12345.678, -9.5e5, np.nan], size=1000, name="v" + ) + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 4 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 0.001\n" + "1 161 12345.678\n" + "2 222 -950000.000\n" + "3 283 NaN" + ) + v = Vector.from_coo([100, 161, 222], [1.23456, -0.5, 1e-4], size=1000, name="v") + with display_options("display.precision", 3): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 3 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 1.235e+00\n" + "1 161 -5.000e-01\n" + "2 222 1.000e-04" + ) + + +def test_sparse_complex_repr(): + values = [ + 1 + 2j, + -1.5 - 0.25j, + 3j, + complex(np.nan, 1), + complex(1, np.nan), + complex(np.nan, -2), + complex(np.nan, np.nan), + 1 + 100j, + ] + v = Vector.from_coo([100, 161, 222, 283, 344, 405, 466, 527], values, size=1000, name="v") + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 8 1000 FC64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 1.00+ 2.00j\n" + "1 161 -1.50- 0.25j\n" + "2 222 0.00+ 3.00j\n" + "3 283 NaN+ 1.00j\n" + "4 344 1.00+ NaNj\n" + "5 405 NaN- 2.00j\n" + "6 466 NaN+ NaNj\n" + "7 527 1.00+100.00j" + ) + html_printer(v, "v") + assert repr_html(v) == ( + "
" + f"{CSS_STYLE}" + '
v
\n' + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
gb.Vector
nvals
size
dtype
format
81000FC64sparse
\n" + "
\n" + "
\n" + "\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
indexval
01001.00+  2.00j
1161-1.50-  0.25j
22220.00+  3.00j
3283NaN+  1.00j
43441.00+   NaNj
5405NaN-  2.00j
6466NaN+   NaNj
75271.00+100.00j
\n" + "
" + ) + v = Vector.from_coo([100, 161], [1e-8 + 1j, 2 - 3j], size=1000, name="v") + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 2 1000 FC64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 0.0+1.0j\n" + "1 161 2.0-3.0j" + ) + v = Vector.from_coo([100, 161], [0.1 + 0.2j, -1 - 1j], dtype="FC32", size=1000, name="v") + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 2 1000 FC32 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 0.1+0.2j\n" + "1 161 -1.0-1.0j" + ) + + +def test_sparse_repr_chop_threshold(): + # A value no larger than display.chop_threshold shows as zero. Whether the column + # uses scientific notation is decided from the values before they are chopped. + v = Vector.from_coo([100, 161, 222, 283], [1e-8, 0.5, -1e-9, np.nan], size=1000, name="v") + with display_options("display.chop_threshold", 1e-6): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 4 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 0.000000e+00\n" + "1 161 5.000000e-01\n" + "2 222 0.000000e+00\n" + "3 283 NaN" + ) + v = Vector.from_coo([100, 161], [1.5, -0.25], size=1000, name="v") + with display_options("display.chop_threshold", 1e-6): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 2 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 1.50\n" + "1 161 -0.25" + ) + # A complex value is compared by its magnitude, so a small part is not chopped + v = Vector.from_coo([100, 161], [1e-8 + 1j, 2 - 1e-9j], size=1000, name="v") + with display_options("display.chop_threshold", 1e-6): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 2 1000 FC64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 0.0+1.0j\n" + "1 161 2.0-0.0j" + ) + + +def test_repr_float_format(): + # A float_format callable replaces the fixed-point and scientific formatting. + # A missing value is "NaN" in COO form; the dense grid already holds it as "nan". + v = Vector.from_coo([100, 161, 222], [1234.5, np.nan, -0.001], size=1000, name="v") + with display_options("display.float_format", "{:,.2f}".format): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 3 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 1,234.50\n" + "1 161 NaN\n" + "2 222 -0.00" + ) + v = Vector.from_coo([0, 1, 3], [1234.5, np.nan, -0.001], size=4, name="v") + with display_options("display.float_format", "{:,.2f}".format): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 3 4 FP64 bitmap\n" + "-------------------------------------\n" + "index 0 1 2 3\n" + "value 1,234.50 nan -0.00" + ) + v = Vector.from_coo([100, 161], [1 + 2j, complex(np.nan, 1)], size=1000, name="v") + with display_options("display.float_format", "{:.2f}".format): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 2 1000 FC64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 1.00+2.00j\n" + "1 161 NaN" + ) + + +def test_sparse_bool_repr(): + v = Vector.from_coo([100, 161, 222], [True, False, True], size=1000, name="v") + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 3 1000 BOOL sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 True\n" + "1 161 False\n" + "2 222 True" + ) + + +def test_sparse_float_repr_truncated(): + # Past display.max_rows the COO form ends in a "..." row, which makes every column + # an object column: each float is formatted on its own, and NaN shows as "NaN". + v = Vector.from_coo( + [100, 161, 222, 283, 344], [np.nan, 1.5, 2.0, 3.0, 4.0], size=1000, name="v" + ) + with display_options("display.max_rows", 4, "display.min_rows", 2): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 5 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 NaN\n" + "1 161 1.5\n" + "... ... ..." + ) + html_printer(v, "v") + assert repr_html(v) == ( + "
" + f"{CSS_STYLE}" + '
v
\n' + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
gb.Vector
nvals
size
dtype
format
51000FP64sparse
\n" + "
\n" + "
\n" + "\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" * 2 + " \n" + " \n" + "
indexval
0100NaN
11611.5
......
\n" + "
" + ) + + def test_infix_expr_repr_html(A, B, v): html_printer(v & v, "v & v") assert repr_html(v & v) == ( From 54ddfcc8d5b04a6242878972c01e5b31fb2d818c Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 07:23:02 -0500 Subject: [PATCH 05/19] Test the repr's display options for widths, headers, wrapping and rows The hand renderer honors pandas display options that no test set: max_colwidth cutting a cell short, colheader_justify "left" and "center", html.use_mathjax off, a frame wider than display.width wrapping into blocks, expand_frame_repr off, max_rows=0 sizing the rows to the terminal's height, and max_rows or max_columns leaving room for a single row or column, where the first one is kept instead of a head and a tail. Pin each, with expected strings from main's pandas renderer. --- graphblas/tests/test_formatting.py | 349 +++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index c81847efb..0cab2acf3 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -4131,6 +4131,355 @@ def test_sparse_float_repr_truncated(): ) +def test_repr_max_colwidth(): + # Cells wider than display.max_colwidth are cut short with "..." + v = Vector.from_coo([100, 161], [1e10, -0.5], size=1000, name="v") + with display_options("display.max_colwidth", 8): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 2 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 1.00...\n" + "1 161 -5.00..." + ) + + +def test_repr_colheader_justify(): + v = Vector.from_coo([100, 161], [1.5, -2.25], size=1000, name="v") + with display_options("display.colheader_justify", "left"): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 2 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val \n" + "0 100 1.50\n" + "1 161 -2.25" + ) + with display_options("display.colheader_justify", "center"): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 2 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 1.50\n" + "1 161 -2.25" + ) + html_printer(v, "v") + assert repr_html(v) == ( + "
" + f"{CSS_STYLE}" + '
v
\n' + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
gb.Vector
nvals
size
dtype
format
21000FP64sparse
\n" + "
\n" + "
\n" + "\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
indexval
01001.50
1161-2.25
\n" + "
" + ) + + +def test_repr_html_no_mathjax(): + v = Vector.from_coo([100, 161], [1.5, -2.25], size=1000, name="v") + with display_options("display.html.use_mathjax", False): + html_printer(v, "v") + assert repr_html(v) == ( + "
" + f"{CSS_STYLE}" + '
v
\n' + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
gb.Vector
nvals
size
dtype
format
21000FP64sparse
\n" + "
\n" + "
\n" + "\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
indexval
01001.50
1161-2.25
\n" + "
" + ) + + +def test_repr_wraps_wide_frame(): + # A frame wider than display.width wraps into blocks, each but the last ending in a + # backslash column. With expand_frame_repr off it is never wrapped. + A = Matrix.from_coo( + [0, 0, 1, 1], [0, 11, 3, 7], [1.5, -2.0, 1e3, 0.25], nrows=2, ncols=12, name="A" + ) + with display_options("display.max_columns", 30, "display.width", 40): + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 4 2 12 FP64 bitmapr\n" + "----------------------------------------------\n" + " 0 1 2 3 4 5 6 7 \\\n" + "0 1.5 \n" + "1 1000.0 0.25 \n" + "\n" + " 8 9 10 11 \n" + "0 -2.0 \n" + "1 " + ) + with display_options("display.max_columns", 30, "display.width", 20): + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 4 2 12 FP64 bitmapr\n" + "----------------------------------------------\n" + " 0 1 2 \\\n" + "0 1.5 \n" + "1 \n" + "\n" + " 3 4 5 \\\n" + "0 \n" + "1 1000.0 \n" + "\n" + " 6 7 8 9 \\\n" + "0 \n" + "1 0.25 \n" + "\n" + " 10 11 \n" + "0 -2.0 \n" + "1 " + ) + with display_options("display.max_columns", 30, "display.expand_frame_repr", False): + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 4 2 12 FP64 bitmapr\n" + "----------------------------------------------\n" + " 0 1 2 3 4 5 6 7 8 9 10 11\n" + "0 1.5 -2.0\n" + "1 1000.0 0.25 " + ) + + +def test_repr_max_rows_zero(monkeypatch): + # display.max_rows=0 fits the rows to the terminal's height (LINES), less three + # lines for the header, the dots and the prompt. + v = Vector.from_coo([100, 161, 222], [1.5, -2.25, np.nan], size=1000, name="v") + monkeypatch.setenv("LINES", "30") + with display_options("display.max_rows", 0): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 3 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 1.50\n" + "1 161 -2.25\n" + "2 222 NaN" + ) + v = Vector.from_coo( + [100 + 61 * i for i in range(14)], [1.5 * i for i in range(14)], size=1000, name="v" + ) + monkeypatch.setenv("LINES", "8") + with display_options("display.max_rows", 0): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 14 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 0.0\n" + "1 161 1.5\n" + ".. ... ...\n" + "9 649 13.5\n" + "... ... ..." + ) + + +def test_repr_max_rows_one(): + # With room for a single row, the first row is shown instead of a head and a tail + v = Vector.from_coo( + [100 + 61 * i for i in range(14)], [1.5 * i for i in range(14)], size=1000, name="v" + ) + with display_options("display.max_rows", 1, "display.min_rows", 1): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 14 1000 FP64 sparse\n" + "-------------------------------------\n" + " index val\n" + "0 100 0.0\n" + ".. ... ..." + ) + + +def test_repr_max_columns_one(): + # With room for a single column, the first column is shown instead of both ends + A = Matrix.from_coo( + [100, 300, 500], [0, 0, 0], [1.5, -2.25, np.nan], nrows=1000, ncols=1, name="A" + ) + with display_options("display.max_columns", 1): + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 3 1000 1 FP64 csc\n" + "---------------------------------------------\n" + " row ...\n" + "0 100 ...\n" + "1 300 ...\n" + "2 500 ..." + ) + html_printer(A, "A") + assert repr_html(A) == ( + "
" + f"{CSS_STYLE}" + '
A
\n' + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
gb.Matrix
nvals
nrows
ncols
dtype
format
310001FP64csc
\n" + "
\n" + "
\n" + "\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
row...
0100...
1300...
2500...
\n" + "
" + ) + + def test_infix_expr_repr_html(A, B, v): html_printer(v & v, "v & v") assert repr_html(v & v) == ( From 9d74984afbebdffbb4dfad64edfe431275823166 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 07:23:11 -0500 Subject: [PATCH 06/19] Test the HTML repr of empty objects and a one-field record The HTML repr renders a table even for an object with no rows or no columns, where the text repr stops at the header, so its columns can be empty. A record UDT with one field shows as a 1-tuple with its trailing comma. Pin both, with expected strings from main's pandas renderer. Two branches that no input reaches are excluded from coverage: the object-column formatter's case for a None cell, kept from pandas (its cells are numpy values or the dense grid's, which blanks None), and _trim_zeros_complex's guard for no values (it runs only for a complex column, and a column with no values becomes a float array). --- graphblas/core/formatting.py | 6 +- graphblas/tests/test_formatting.py | 183 +++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/graphblas/core/formatting.py b/graphblas/core/formatting.py index 8098efa32..940b7b1e9 100644 --- a/graphblas/core/formatting.py +++ b/graphblas/core/formatting.py @@ -427,7 +427,8 @@ def _trim_zeros_complex(str_complexes): imag_part.append("".join(trimmed[-4:-2])) n = len(str_complexes) padded_parts = _trim_zeros_float(real_part + imag_part) - if len(padded_parts) == 0: + if len(padded_parts) == 0: # pragma: no cover (defensive) + # An empty column becomes a float array, so it never comes here return [] padded_length = max(len(part) for part in padded_parts) - 1 return [ @@ -684,7 +685,8 @@ def _format_col_raw(self, col): out.append(float_format(v)) else: out.append(_trim_zeros_single_float(f"{v: .{precision}f}")) - elif v is None: + elif v is None: # pragma: no cover (defensive) + # Cells come from numpy values or the dense grid, which blanks None out.append(" None") elif _is_float_scalar(v): out.append(" NaN") diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 0cab2acf3..646094fc6 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -5791,6 +5791,22 @@ def test_udt(): ) +def test_udt_one_field_repr(): + # A record with one field shows as a 1-tuple, trailing comma included + udt = dtypes.register_anonymous(np.dtype([("one_field_x", np.int16)]), "one_field") + v = Vector(udt, size=3, name="v") + v[0] = (5,) + v[2] = (-7,) + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 2 3 one_field bitmap\n" + "-----------------------------------------\n" + "index 0 1 2\n" + "value (5,) (-7,)" + ) + + def test_empty(): v = Vector(int, 0) repr_printer(v, "v") @@ -5815,6 +5831,173 @@ def test_empty(): ) +def test_empty_repr_html(): + # The text repr of an object with no rows or columns stops at the header; the + # HTML repr still shows the table, with its row and column labels. + A = Matrix(int, 0, 5, name="A") + html_printer(A, "A") + assert repr_html(A) == ( + "
" + f"{CSS_STYLE}" + '
A
\n' + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" * 2 + " \n" + " \n" + " \n" + " \n" + "
gb.Matrix
nvals
nrows
ncols
dtype
format
05INT64csr
\n" + "
\n" + "
\n" + "\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
01234
\n" + "
" + ) + A = Matrix(int, 5, 0, name="A") + html_printer(A, "A") + assert repr_html(A) == ( + "
" + f"{CSS_STYLE}" + '
A
\n' + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
gb.Matrix
nvals
nrows
ncols
dtype
format
050INT64hypercsr
\n" + "
\n" + "
\n" + "\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
0
1
2
3
4
\n" + "
" + ) + v = Vector(int, 0, name="v") + html_printer(v, "v") + assert repr_html(v) == ( + "
" + f"{CSS_STYLE}" + '
v
\n' + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" * 2 + " \n" + " \n" + " \n" + "
gb.Vector
nvals
size
dtype
format
0INT64sparse
\n" + "
\n" + "
\n" + "\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
\n" + "
" + ) + + def test_vector_as_matrix(): # Built iso outright: the repr asserted below names the format, and # SuiteSparse 10.5.0 no longer infers iso from a build whose values happen From 6dba278d3c06ef86ea64f79b3f4bff2404e9bb7c Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 07:27:43 -0500 Subject: [PATCH 07/19] Pin two reprs that deliberately differ from pandas' output "Render rich reprs without pandas, byte-identical to pandas output" lists two places where the hand renderer departs from the pandas-based one on purpose. With display.max_colwidth of 3 or less, pandas narrows the column labels too and leaves labels of different digit counts ragged, while the hand renderer keeps them at one padded width. With display.notebook_repr_html off, pandas' DataFrame._repr_html_ returns None, which the old code embedded as the text "None" in place of the table; the table is now shown either way. Pin both. --- graphblas/tests/test_formatting.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 646094fc6..f0791aab0 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -4144,6 +4144,19 @@ def test_repr_max_colwidth(): "0 100 1.00...\n" "1 161 -5.00..." ) + # Deliberately not pandas' output: with a max_colwidth of 3 or less pandas narrows + # the column labels as well, so labels of one and two digits end up ragged; here + # they keep one padded width. + v = Vector.from_coo([0, 1, 11], [1, 2, 3], size=12, name="v") + with display_options("display.max_colwidth", 2): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 3 12 INT64 bitmap\n" + "-------------------------------------\n" + "index 0 1 2 3 4 5 6 7 8 9 10 11\n" + "value 1 2 3" + ) def test_repr_colheader_justify(): @@ -4291,6 +4304,15 @@ def test_repr_html_no_mathjax(): ) +def test_repr_html_ignores_notebook_repr_html(v): + # Deliberately not pandas' output: with display.notebook_repr_html off, pandas' + # DataFrame._repr_html_ returns None, which the pandas-based renderer embedded as + # the text "None" where the table goes. The table is shown either way now. + expected = repr_html(v) + with display_options("display.notebook_repr_html", False): + assert repr_html(v) == expected + + def test_repr_wraps_wide_frame(): # A frame wider than display.width wraps into blocks, each but the last ending in a # backslash column. With expand_frame_repr off it is never wrapped. From a10d03718b33d28f37ce2d0d3f1f39821ffb5b45 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 08:26:56 -0500 Subject: [PATCH 08/19] Show a UDT object too large to show whole without Numba Behavior fix. To read only the rows and columns it shows, the repr copied them into an empty object by assigning a value found with reduce(monoid.any), then the object's own values over it. A UDT has no monoid without Numba, so the repr of any UDT Vector or Matrix with more rows or columns than fit raised there: udt = dtypes.register_anonymous(np.dtype([("x", np.int16)]), "rec") v = Vector(udt, size=300) v[:] = (1,) repr(v) # before, without Numba: KeyError: 'any does not work with (rec, rec) types' # after: the first and last columns, as with Numba On main too. The rows and columns are now extracted and assigned in place, which needs no operator for any dtype, and with Numba no monoid is compiled for a UDT just to show it. The output is unchanged: the new test expects what main's pandas renderer shows with Numba, and every repr pinned in test_formatting.py is unchanged. --- graphblas/core/formatting.py | 22 ++++-------- graphblas/tests/test_formatting.py | 55 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/graphblas/core/formatting.py b/graphblas/core/formatting.py index 940b7b1e9..99b27ede7 100644 --- a/graphblas/core/formatting.py +++ b/graphblas/core/formatting.py @@ -8,7 +8,7 @@ import numpy as np -from .. import backend, config, monoid, unary +from .. import backend, config, unary from ..dtypes import BOOL from ..exceptions import OutOfMemory from .infix import _is_literal @@ -175,22 +175,15 @@ def _update_matrix_array(arr, matrix, rows, row_offset, columns, column_offset, if type(matrix) is TransposedMatrix: parent = matrix._matrix submatrix = Matrix(parent.dtype, parent._nrows, parent._ncols, name="") - if parent._nvals > 0: # pragma: no branch - # Get val to support iso-valued matrices - val = parent.reduce_scalar(monoid.any, allow_empty=False).new(name="") - submatrix(parent.S)[columns, rows] = val - submatrix(submatrix.S)[...] = parent + submatrix[columns, rows] = parent[columns, rows].new(name="") if row_offset is not None or column_offset is not None: submatrix = submatrix[column_offset:, row_offset:].new(name="") submatrix = submatrix.T else: if mask is None: + # Extract and assign need no operator, which a UDT lacks without Numba submatrix = Matrix(matrix.dtype, matrix._nrows, matrix._ncols, name="") - # Get val to support iso-valued matrices - if matrix._nvals > 0: - val = matrix.reduce_scalar(monoid.any).new(name="") - submatrix(matrix.S)[rows, columns] = val - submatrix(submatrix.S)[...] = matrix + submatrix[rows, columns] = matrix[rows, columns].new(name="") else: submatrix = Matrix("UINT8", matrix._nrows, matrix._ncols, name="") if mask.structure: @@ -227,12 +220,9 @@ def _update_vector_array(arr, vector, columns, column_offset, *, mask=None): subvector(vector.V)[...] = 0 if mask.complement else 1 else: if mask is None: + # Extract and assign need no operator, which a UDT lacks without Numba subvector = Vector(vector.dtype, vector._size, name="") - # Get val to support iso-valued vectors - if vector._nvals > 0: - val = vector.reduce(monoid.any).new(name="") - subvector(vector.S)[columns] = val - subvector(subvector.S)[...] = vector + subvector[columns] = vector[columns].new(name="") else: subvector = Vector("UINT8", vector._size, name="") if mask.structure: diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index f0791aab0..15b59afdf 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -5829,6 +5829,61 @@ def test_udt_one_field_repr(): ) +def test_truncated_udt_repr(): + # The rows and columns shown are read with extract and assign, which need no + # operator, so a UDT object too large to show whole also shows without Numba + udt = dtypes.register_anonymous(np.dtype([("tr_x", np.int16)]), "tr_rec") + v = Vector(udt, size=300, name="v") + v[:] = (1,) + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 300 300 tr_rec full (iso)\n" + "------------------------------------------\n" + "index 0 1 2 3 4 5 ... 294 295 296 297 298 299\n" + "value (1,) (1,) (1,) (1,) (1,) (1,) ... (1,) (1,) (1,) (1,) (1,) (1,)" + ) + A = Matrix(udt, nrows=100, ncols=300, name="A") + A[0, :] = (2,) + A[99, 299] = (3,) + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 301 100 300 tr_rec hypercsr\n" + "------------------------------------------------\n" + " 0 1 2 3 4 5 ... 294 295 296 297 298 299\n" + "0 (2,) (2,) (2,) (2,) (2,) (2,) ... (2,) (2,) (2,) (2,) (2,) (2,)\n" + "1 ... \n" + "2 ... \n" + "3 ... \n" + "4 ... \n" + ".. ... ... ... ... ... ... ... ... ... ... ... ... ...\n" + "95 ... \n" + "96 ... \n" + "97 ... \n" + "98 ... \n" + "99 ... (3,)" + ) + repr_printer(A.T, "A.T") + assert repr(A.T) == ( + '"A.T" nvals nrows ncols dtype format\n' + "gb.TransposedMatrix 301 300 100 tr_rec hypercsc\n" + "----------------------------------------------------------\n" + " 0 1 2 3 4 5 6 7 8 9 10 ... 89 90 91 92 93 94 95 96 97 98 99\n" + "0 (2,) ... \n" + "1 (2,) ... \n" + "2 (2,) ... \n" + "3 (2,) ... \n" + "4 (2,) ... \n" + ".. ... .. .. .. .. .. .. .. .. .. .. ... .. .. .. .. .. .. .. .. .. .. ...\n" + "295 (2,) ... \n" + "296 (2,) ... \n" + "297 (2,) ... \n" + "298 (2,) ... \n" + "299 (2,) ... (3,)" + ) + + def test_empty(): v = Vector(int, 0) repr_printer(v, "v") From eafff993399b941d7d015666552cd593e8170758 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 07:30:52 -0500 Subject: [PATCH 09/19] Test two reprs that the pandas-based renderer could not show Two inputs that main's pandas renderer raised on are shown by the hand renderer, so here it is an improvement, not a byte-identical copy: - display.max_rows=0 in a terminal with room for one row: the frame is truncated to no rows, and pandas raised TypeError ("'int' object is not iterable") formatting it. Now the header and a ".." row are shown. - a record UDT in COO form: pandas raised TypeError (isnan on the structured column). Now each entry is a tuple, as in the dense grid. This is the only COO value column that is neither numeric nor a bool. Pin both. --- graphblas/tests/test_formatting.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 15b59afdf..b139a08e4 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -4400,6 +4400,19 @@ def test_repr_max_rows_zero(monkeypatch): "9 649 13.5\n" "... ... ..." ) + # A terminal with room for one row leaves none to show. main's pandas renderer + # raised TypeError here (pandas fails on a frame truncated to no rows). + v = Vector.from_coo([100, 161, 222], [1 + 2j, -1j, 2.5], size=1000, name="v") + monkeypatch.setenv("LINES", "4") + with display_options("display.max_rows", 0): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 3 1000 FC64 sparse\n" + "-------------------------------------\n" + " index val\n" + ".. ... ..." + ) def test_repr_max_rows_one(): @@ -5827,6 +5840,19 @@ def test_udt_one_field_repr(): "index 0 1 2\n" "value (5,) (-7,)" ) + # In COO form too, where main's pandas renderer raised TypeError on the record column + v = Vector(udt, size=1000, name="v") + v[100] = (5,) + v[500] = (-7,) + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 2 1000 one_field sparse\n" + "-----------------------------------------\n" + " index val\n" + "0 100 (5,)\n" + "1 500 (-7,)" + ) def test_truncated_udt_repr(): From b10a1ba380f5931d3f06d8688f16bcbe77c66bfc Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 07:31:45 -0500 Subject: [PATCH 10/19] Show an array UDT's values in a sparse repr Behavior fix. repr and _repr_html_ of a sparse Vector or Matrix of an array UDT raised whenever the values were shown in COO form: udt = dtypes.register_anonymous(np.dtype((np.int8, (3,))), "int8x3") v = Vector(udt, size=1000) v[100] = (1, 2, 3) repr(v) # main: ValueError: Per-column arrays must each be 1-dimensional # (from pandas.DataFrame) # before: TypeError: unsupported format string passed to list.__format__ The COO value column got the int formatter from the 2-D values' element type. Its entries are now lists, formatted as objects, as the dense grid already shows them; this is what pandas renders for the same frame with one list per entry, in text and HTML: index val 0 100 [1, 2, 3] 1 500 [-1, 0, 1] --- graphblas/core/formatting.py | 3 + graphblas/tests/test_formatting.py | 147 +++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/graphblas/core/formatting.py b/graphblas/core/formatting.py index 99b27ede7..5c8cf7f17 100644 --- a/graphblas/core/formatting.py +++ b/graphblas/core/formatting.py @@ -357,6 +357,9 @@ def _make_coo_frame(label_arrays, add_dots): values = np.asarray(values) if add_dots: out.append(_Column(label, [*values.tolist(), "..."], "object", False)) + elif values.ndim > 1: + # Array UDT values: a list per entry, as the dense grid shows them + out.append(_Column(label, values.tolist(), "object", False)) else: kind, numeric = _dtype_kind_numeric(values.dtype) out.append(_Column(label, values.tolist(), kind, numeric)) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index b139a08e4..5d7d46551 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -5910,6 +5910,153 @@ def test_truncated_udt_repr(): ) +def test_sparse_array_udt_repr(): + # An array UDT in COO form shows a list per entry, as the dense grid does + udt = dtypes.register_anonymous(np.dtype((np.int8, (3,))), "int8x3") + v = Vector(udt, size=1000, name="v") + v[100] = (1, 2, 3) + v[500] = (-1, 0, 1) + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 2 1000 int8x3 sparse\n" + "--------------------------------------\n" + " index val\n" + "0 100 [1, 2, 3]\n" + "1 500 [-1, 0, 1]" + ) + html_printer(v, "v") + assert repr_html(v) == ( + "
" + f"{CSS_STYLE}" + '
v
\n' + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
gb.Vector
nvals
size
dtype
format
21000int8x3sparse
\n" + "
\n" + "
\n" + "\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
indexval
0100[1, 2, 3]
1500[-1, 0, 1]
\n" + "
" + ) + A = Matrix(udt, nrows=1000, ncols=1000, name="A") + A[100, 5] = (1, 2, 3) + A[300, 7] = (4, -5, 6) + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 2 1000 1000 int8x3 hypercsr\n" + "------------------------------------------------\n" + " row col val\n" + "0 100 5 [1, 2, 3]\n" + "1 300 7 [4, -5, 6]" + ) + html_printer(A, "A") + assert repr_html(A) == ( + "
" + f"{CSS_STYLE}" + '
A
\n' + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" * 2 + " \n" + " \n" + " \n" + "
gb.Matrix
nvals
nrows
ncols
dtype
format
21000int8x3hypercsr
\n" + "
\n" + "
\n" + "\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
rowcolval
01005[1, 2, 3]
13007[4, -5, 6]
\n" + "
" + ) + + def test_empty(): v = Vector(int, 0) repr_printer(v, "v") From 75d2e05cd02f7fd815475136428c983f326c6251 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 07:32:10 -0500 Subject: [PATCH 11/19] Label the dtypes of a Matrix infix expression's HTML repr by side Behavior fix. The HTML repr of a Matrix infix expression, such as A | B or A @ B, put the left operand's dtype under "right_dtype" and the right operand's under "left_dtype". Its text repr, and the Vector and Scalar HTML reprs, label them correctly. With A INT8 and B FP64: (A | B)._repr_html_() # before: right_dtype INT8, left_dtype FP64 # after: left_dtype INT8, right_dtype FP64 On main too, since "Add reprs for infix expressions!" (502ed2a7, first released in 1.3.4). Five expected strings pinned the swapped labels, with both operands of one dtype so the swap never showed in the values: A & B.T, A | A, A @ B and A.T @ B.T in test_infix_expr_repr_html, and A & A in test_autocompute_html. They now expect the labels in order, and a new case, A | F with INT64 and FP64 operands, checks each dtype sits under its own side's label. --- graphblas/core/formatting.py | 2 +- graphblas/tests/test_formatting.py | 35 +++++++++++++++++++++++++----- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/graphblas/core/formatting.py b/graphblas/core/formatting.py index 5c8cf7f17..74d8e495d 100644 --- a/graphblas/core/formatting.py +++ b/graphblas/core/formatting.py @@ -1489,7 +1489,7 @@ def format_matrix_infix_expression_html(expr): expr_html = expr._format_expr_html() header = create_header_html( expr_html, - ["nrows", "ncols", "right_dtype", "left_dtype"], + ["nrows", "ncols", "left_dtype", "right_dtype"], [expr._nrows, expr._ncols, expr.left.dtype, expr.right.dtype], ) return _format_infix_expression(expr, header, expr_html) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 5d7d46551..46bccac0a 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -4610,8 +4610,8 @@ def test_infix_expr_repr_html(A, B, v): '
A1 & B1.T
\n' "
nrows
\n" "
ncols
\n" - "
right_dtype
\n" "
left_dtype
\n" + "
right_dtype
\n" " \n" " \n" " 1\n" @@ -4633,8 +4633,8 @@ def test_infix_expr_repr_html(A, B, v): '
A1 | A1
\n' "
nrows
\n" "
ncols
\n" - "
right_dtype
\n" "
left_dtype
\n" + "
right_dtype
\n" " \n" " \n" " 1\n" @@ -4646,6 +4646,31 @@ def test_infix_expr_repr_html(A, B, v): "\n" "" ) + # Operands of two dtypes, so each dtype must sit under its own side's label + F = Matrix.from_coo([0], [1], [2.5], nrows=1, ncols=5, name="F") + html_printer(A | F, "A | F") + assert repr_html(A | F) == ( + "
" + f"{CSS_STYLE}" + '
gb.MatrixEwiseAddExpr ?Do op(expr) to create a MatrixExpression for ewise_add.
For example: plus(A1 | F)
\n' + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
A1 | F
nrows
ncols
left_dtype
right_dtype
15INT64FP64
\n" + "
\n" + "
" + ) html_printer(A @ B, "A @ B") assert repr_html(A @ B) == ( "
" @@ -4656,8 +4681,8 @@ def test_infix_expr_repr_html(A, B, v): '
A1 @ B1
\n' "
nrows
\n" "
ncols
\n" - "
right_dtype
\n" "
left_dtype
\n" + "
right_dtype
\n" " \n" " \n" " 1\n" @@ -4679,8 +4704,8 @@ def test_infix_expr_repr_html(A, B, v): '
A1.T @ B1.T
\n' "
nrows
\n" "
ncols
\n" - "
right_dtype
\n" "
left_dtype
\n" + "
right_dtype
\n" " \n" " \n" " 5\n" @@ -4994,8 +5019,8 @@ def test_autocompute_html(A, B, v): '
A1 & A1
\n' "
nrows
\n" "
ncols
\n" - "
right_dtype
\n" "
left_dtype
\n" + "
right_dtype
\n" " \n" " \n" " 1\n" From 786c774578a680af14faf391d0837c0164e31575 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 07:39:37 -0500 Subject: [PATCH 12/19] Read the rows and columns a repr shows for any max_rows and min_rows Behavior fix. A Matrix or Vector shown as a grid reads only the rows and columns the renderer will show: the first and last half of what fits, with blank filler between them that the renderer cuts. _get_chunk sized those halves from min_rows alone, and from max_columns for columns, which broke whenever that disagreed with what the renderer shows (min(min_rows, max_rows), or max_rows when min_rows is 0, as pandas does), or when the half was 0. On main too, with pandas: display.max_columns=1, or min_rows=1 or 0 # TypeError: Invalid dtype for index: float64 (nothing was read) display.max_rows=4 (below the default min_rows=10), 40x3 Matrix # rows 35 and 36 shown in place of rows 0 and 1 (and row 0's # value under the label 35 when row 35 is empty) display.max_rows=2, 3x3 Matrix # IndexOutOfBound (the halves overlapped) display.max_rows=0 (fit the terminal), 40x3 Matrix, 8-line terminal # rows 35 to 39 alone, under no "..", and row 0's value at 35 # when row 35 is empty The halves now come from the count the renderer shows, the first max_rows (or max_columns) items are read when that count is 1, and with max_rows=0 the count is the terminal's rows, which the renderer uses then. A Matrix is shown in COO form instead when the entries that form would show outnumber twice the values read for the grid; that count of entries now stops at the rows the renderer shows too, so reading fewer rows does not tip a dense Matrix into COO form. Every case above now shows what pandas shows for the whole frame, as does a grid of max_rows, min_rows, max_columns and terminal heights over several shapes, wherever the repr keeps the grid form and pandas does not raise itself. display.max_rows, min_rows or max_columns set to None still raises, as on main. --- graphblas/core/formatting.py | 36 +++++++-- graphblas/tests/test_formatting.py | 126 +++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 7 deletions(-) diff --git a/graphblas/core/formatting.py b/graphblas/core/formatting.py index 74d8e495d..91ffdab11 100644 --- a/graphblas/core/formatting.py +++ b/graphblas/core/formatting.py @@ -254,15 +254,29 @@ def _get_max_columns(): return max_columns +def _terminal_rows(): + # rows available for data: terminal height minus dots + prompt + header + return shutil.get_terminal_size()[1] - 3 + + def _get_chunk(length, min_length, max_length): if length <= max_length: chunk = list(range(length)) chunk_groups = [(None, None)] else: - half = min_length // 2 + # The renderer cuts a frame longer than max_length to the first and last half of + # min(min_length, max_length) items, or of max_length when min_length is 0. Only + # those are read: the frame is one item longer than max_length, and the items + # between them are blank and cut. + shown = min(min_length, max_length) if min_length else max_length + half = shown // 2 + chunk = list(range(max_length + 1)) + if half == 0: + # With room for one item the renderer shows the first max_length items + # instead of a head and a tail. + return chunk, [(list(range(max_length)), None)] first_chunk = list(range(half)) second_chunk = list(range(length - half, length)) - chunk = list(range(max_length + 1)) chunk[-half:] = second_chunk chunk_groups = [(first_chunk, None), (second_chunk, length - len(chunk))] return chunk, chunk_groups @@ -611,8 +625,7 @@ def _calc_max_cols_fitted(self): def _calc_max_rows_fitted(self): if self._is_in_terminal() and self.max_rows == 0: - # rows available for data: terminal height minus dots + prompt + header - return shutil.get_terminal_size()[1] - 3 + return _terminal_rows() max_rows = self.max_rows if max_rows and self.frame.nrows > max_rows and self.min_rows: max_rows = min(self.min_rows, max_rows) @@ -916,7 +929,13 @@ def _get_matrix_dataframe(matrix, max_rows, min_rows, max_columns, *, mask=None) min_rows = _display_option("min_rows") if max_columns is None: # pragma: no branch max_columns = _get_max_columns() - rows, row_groups = _get_chunk(matrix._nrows, min_rows, max_rows) + if max_rows == 0: + # The renderer fits the rows to the terminal; read at least the first row + fit_rows = max(_terminal_rows(), 1) + rows, row_groups = _get_chunk(matrix._nrows, fit_rows, fit_rows) + else: + fit_rows = max_rows + rows, row_groups = _get_chunk(matrix._nrows, min_rows, max_rows) columns, column_groups = _get_chunk(matrix._ncols, max_columns, max_columns) arr = np.full((len(rows), len(columns)), np.nan, dtype=object) for row_group, row_offset in row_groups: @@ -932,10 +951,13 @@ def _get_matrix_dataframe(matrix, max_rows, min_rows, max_columns, *, mask=None) ) present = _count_present(arr) truncated = (len(rows), len(columns)) != matrix.shape + # Weigh the COO form by the entries it would show: the renderer cuts it to the rows it + # shows, as it does the grid + coo_rows = matrix._nvals if matrix._nvals <= max_rows else min(min_rows, fit_rows) if ( (mask is None or mask.structure) and truncated - and min(matrix._nvals, max_rows if matrix._nvals <= max_rows else min_rows) > 2 * present + and min(matrix._nvals, coo_rows) > 2 * present ): # The data is sparse and it's better to show in COO format. # SS, SuiteSparse-specific: head @@ -957,7 +979,7 @@ def _get_matrix_dataframe(matrix, max_rows, min_rows, max_columns, *, mask=None) # Perhaps we could use the efficient "constant vector or matrix" trick. nonzero = matrix.apply(unary.one["UINT8"]).new(mask=matrix.V, name="") num_rows = matrix._nvals if matrix._nvals <= max_rows else min_rows - if min(nonzero._nvals, num_rows) > 2 * present: + if min(nonzero._nvals, coo_rows) > 2 * present: rows, cols, vals = nonzero.ss.head(num_rows, sort=True) if mask.complement: if not vals.flags.writeable: # pragma: no cover (safety) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 46bccac0a..25c6efb8a 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -4413,6 +4413,25 @@ def test_repr_max_rows_zero(monkeypatch): " index val\n" ".. ... ..." ) + # A Matrix shown as a grid reads the rows that fit, not min_rows of them + rows = [0, 1, 2, 3, 36, 37, 38, 39] + A = Matrix.from_coo( + rows, [0, 1, 2, 0, 1, 2, 0, 1], [r * 10 + 1 for r in rows], nrows=40, ncols=3, name="A" + ) + monkeypatch.setenv("LINES", "8") + with display_options("display.max_rows", 0): + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 8 40 3 INT64 bitmapr\n" + "----------------------------------------------\n" + " 0 1 2 \n" + "0 1 \n" + "1 11 \n" + ".. ... ... ..\n" + "38 381 \n" + "39 391 " + ) def test_repr_max_rows_one(): @@ -4430,6 +4449,88 @@ def test_repr_max_rows_one(): "0 100 0.0\n" ".. ... ..." ) + # display.min_rows=1 leaves room for one row too, so the first max_rows rows are shown + B = Matrix.from_coo( + [0, 1, 3, 20, 39], [0, 1, 2, 0, 2], [1, 2, 3, 4, 5], nrows=40, ncols=3, name="B" + ) + with display_options("display.max_rows", 5, "display.min_rows", 1): + repr_printer(B, "B") + assert repr(B) == ( + '"B" nvals nrows ncols dtype format\n' + "gb.Matrix 5 40 3 INT64 hypercsr\n" + "-----------------------------------------------\n" + " 0 1 2\n" + "0 1 \n" + "1 2 \n" + "2 \n" + "3 3\n" + "4 \n" + ".. .. .. .." + ) + + +def test_repr_max_rows_below_min_rows(): + # The rows shown are the first and last half of min(min_rows, max_rows), or of + # max_rows when min_rows is 0, so only those rows are read, each under its label + rows = [0, 1, 2, 3, 36, 37, 38, 39] + A = Matrix.from_coo( + rows, [0, 1, 2, 0, 1, 2, 0, 1], [r * 10 + 1 for r in rows], nrows=40, ncols=3, name="A" + ) + with display_options("display.max_rows", 4): + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 8 40 3 INT64 bitmapr\n" + "----------------------------------------------\n" + " 0 1 2 \n" + "0 1 \n" + "1 11 \n" + ".. ... ... ..\n" + "38 381 \n" + "39 391 " + ) + with display_options("display.max_rows", 6, "display.min_rows", 0): + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 8 40 3 INT64 bitmapr\n" + "----------------------------------------------\n" + " 0 1 2\n" + "0 1 \n" + "1 11 \n" + "2 21\n" + ".. ... ... ...\n" + "37 371\n" + "38 381 \n" + "39 391 " + ) + # Fewer rows than min_rows + C = Matrix.from_coo([0, 1, 2], [0, 1, 2], [1, 2, 3], nrows=3, ncols=3, name="C") + with display_options("display.max_rows", 2): + repr_printer(C, "C") + assert repr(C) == ( + '"C" nvals nrows ncols dtype format\n' + "gb.Matrix 3 3 3 INT64 bitmapr\n" + "----------------------------------------------\n" + " 0 1 2\n" + "0 1 \n" + ".. .. .. ..\n" + "2 3" + ) + # A dense Matrix stays a grid: the COO form would show no more rows than the grid + vals = np.arange(5)[:, None] * 1000 + np.arange(4)[None, :] + 1 + D = Matrix.from_dense(vals, name="D") + with display_options("display.max_rows", 3, "display.max_columns", 2): + repr_printer(D, "D") + assert repr(D) == ( + '"D" nvals nrows ncols dtype format\n' + "gb.Matrix 20 5 4 INT64 fullr\n" + "---------------------------------------------\n" + " 0 ... 3\n" + "0 1 ... 4\n" + ".. ... ... ...\n" + "4 4001 ... 4004" + ) def test_repr_max_columns_one(): @@ -4513,6 +4614,31 @@ def test_repr_max_columns_one(): "\n" "
" ) + # The same for a Matrix and a Vector shown as a grid + A = Matrix.from_coo( + [0, 1, 2, 0, 2], [0, 0, 0, 3, 4], [1, 2, 3, 4, 5], nrows=3, ncols=5, name="A" + ) + with display_options("display.max_columns", 1): + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 5 3 5 INT64 bitmapr\n" + "----------------------------------------------\n" + " 0 ...\n" + "0 1 ...\n" + "1 2 ...\n" + "2 3 ..." + ) + v = Vector.from_coo([0, 4], [1, 2], size=5, name="v") + with display_options("display.max_columns", 1): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 2 5 INT64 bitmap\n" + "-------------------------------------\n" + "index 0 ...\n" + "value 1 ..." + ) def test_infix_expr_repr_html(A, B, v): From 8450cdaace8ba480cbec52259f1af37ec8683373 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 07:49:30 -0500 Subject: [PATCH 13/19] Iterate over the values of a UDT Vector or Matrix Behavior fix. ss.itervalues and ss.iteritems looked up lib.GxB_Iterator_get_, which exists only for built-in types, so for any UDT they raised AttributeError, and so did ss.head(n) with n below nvals, which iterates. The repr reads the first values with head, so a sparse UDT object with more values than display.max_rows could not be shown: udt = dtypes.register_anonymous(np.dtype([("x", np.int16)]), "rec") v = Vector(udt, size=1000) v[100:800:10] = (1,) repr(v) # before: AttributeError: cffi library 'suitesparse_graphblas._graphblas' # has no function, constant or global variable named # 'GxB_Iterator_get_rec' # after: the first display.min_rows entries in COO form, then "..." On main too, since the iterators were added (#201, 2022.4.1). A UDT's value is now read with GxB_Iterator_get_UDT (in every python-suitesparse-graphblas from 7.4.0.0 on) into a buffer and converted as Scalar.value converts it: a record or an array. head fills its result from a list, since numpy reads a tuple of records as one record, and an array UDT's dtype would broadcast each array value to its own shape. --- graphblas/core/ss/matrix.py | 24 +++++++- graphblas/core/ss/vector.py | 12 ++-- graphblas/tests/test_formatting.py | 96 ++++++++++++++++++++++++++++++ graphblas/tests/test_matrix.py | 25 ++++++++ graphblas/tests/test_vector.py | 21 +++++++ 5 files changed, 171 insertions(+), 7 deletions(-) diff --git a/graphblas/core/ss/matrix.py b/graphblas/core/ss/matrix.py index d39eb9145..9ad43721a 100644 --- a/graphblas/core/ss/matrix.py +++ b/graphblas/core/ss/matrix.py @@ -18,6 +18,7 @@ from ..base import call from ..operator import get_typed_op from ..scalar import Scalar, _as_scalar, _scalar_index +from ..udt import _element_value from ..utils import ( _CArray, _distinct_buffers, @@ -49,6 +50,19 @@ def njit(func=None, **kwargs): ffi_new = ffi.new +def _iterator_value_getter(dtype): + """The function returning the value of the entry an iterator is on.""" + if not dtype._is_udt: + return getattr(lib, f"GxB_Iterator_get_{dtype.name}") + buf = ffi_new("char[]", dtype.np_type.itemsize) + + def get_udt(it): + lib.GxB_Iterator_get_UDT(it, buf) + return _element_value(dtype, buf) + + return get_udt + + def head(matrix, n=10, dtype=None, *, sort=False): """Like ``matrix.to_coo()``, but only returns the first n elements. @@ -67,7 +81,11 @@ def head(matrix, n=10, dtype=None, *, sort=False): else: dtype = lookup_dtype(dtype) rows, cols, vals = zip(*itertools.islice(matrix.ss.iteritems(), n), strict=True) - return np.array(rows, np.uint64), np.array(cols, np.uint64), np.array(vals, dtype.np_type) + # Filled from a list: numpy reads a tuple of records as one record, and an array + # UDT's dtype would add its shape to that of each value. + values = np.empty(len(vals), dtype.np_type) + values[...] = list(vals) + return np.array(rows, np.uint64), np.array(cols, np.uint64), values def _concat_mn(tiles, *, is_matrix=None): @@ -499,7 +517,7 @@ def itervalues(self, seek=0): return it = it_ptr[0] info = success = lib.GrB_SUCCESS - val_func = getattr(lib, f"GxB_Iterator_get_{self._parent.dtype.name}") + val_func = _iterator_value_getter(self._parent.dtype) next_func = lib.GxB_Matrix_Iterator_next try: while info == success: @@ -534,7 +552,7 @@ def iteritems(self, seek=0): it = it_ptr[0] info = success = lib.GrB_SUCCESS key_func = lib.GxB_Matrix_Iterator_getIndex - val_func = getattr(lib, f"GxB_Iterator_get_{self._parent.dtype.name}") + val_func = _iterator_value_getter(self._parent.dtype) next_func = lib.GxB_Matrix_Iterator_next row_ptr = ffi_new("GrB_Index*") col_ptr = ffi_new("GrB_Index*") diff --git a/graphblas/core/ss/vector.py b/graphblas/core/ss/vector.py index 4ad1c3eb9..608dcacbb 100644 --- a/graphblas/core/ss/vector.py +++ b/graphblas/core/ss/vector.py @@ -25,7 +25,7 @@ from ._utils import _pack_securely, _resolve_serialized_dtype, _unclaim_taken from .config import BaseConfig from .descriptor import get_descriptor -from .matrix import _concat_mn, njit +from .matrix import _concat_mn, _iterator_value_getter, njit from .prefix_scan import prefix_scan ffi_new = ffi.new @@ -46,7 +46,11 @@ def head(vector, n=10, dtype=None, *, sort=False): else: dtype = lookup_dtype(dtype) indices, vals = zip(*itertools.islice(vector.ss.iteritems(), n), strict=True) - return np.array(indices, np.uint64), np.array(vals, dtype.np_type) + # Filled from a list: numpy reads a tuple of records as one record, and an array + # UDT's dtype would add its shape to that of each value. + values = np.empty(len(vals), dtype.np_type) + values[...] = list(vals) + return np.array(indices, np.uint64), values class VectorConfig(BaseConfig): @@ -370,7 +374,7 @@ def itervalues(self, seek=0): return it = it_ptr[0] info = success = lib.GrB_SUCCESS - val_func = getattr(lib, f"GxB_Iterator_get_{self._parent.dtype.name}") + val_func = _iterator_value_getter(self._parent.dtype) next_func = lib.GxB_Vector_Iterator_next try: while info == success: @@ -405,7 +409,7 @@ def iteritems(self, seek=0): it = it_ptr[0] info = success = lib.GrB_SUCCESS key_func = lib.GxB_Vector_Iterator_getIndex - val_func = getattr(lib, f"GxB_Iterator_get_{self._parent.dtype.name}") + val_func = _iterator_value_getter(self._parent.dtype) next_func = lib.GxB_Vector_Iterator_next try: while info == success: diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 25c6efb8a..18f376038 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -6208,6 +6208,102 @@ def test_sparse_array_udt_repr(): ) +def test_sparse_udt_repr_past_max_rows(): + # More values than display.max_rows: the first min_rows are read with an iterator + rec = dtypes.register_anonymous( + np.dtype([("rh_x", np.int16), ("rh_y", np.float32)]), "rec_head" + ) + arr = dtypes.register_anonymous(np.dtype((np.int8, (3,))), "int8x3") + v = Vector(rec, size=1000, name="v") + A = Matrix(arr, nrows=1000, ncols=1000, name="A") + for i in range(5): + v[100 + 100 * i] = (i, i / 2) + A[100 + 100 * i, i] = (i, -i, 2 * i) + with display_options("display.max_rows", 4, "display.min_rows", 2): + repr_printer(v, "v") + assert repr(v) == ( + '"v" nvals size dtype format\n' + "gb.Vector 5 1000 rec_head sparse\n" + "----------------------------------------\n" + " index val\n" + "0 100 (0, 0.0)\n" + "1 200 (1, 0.5)\n" + "... ... ..." + ) + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 5 1000 1000 int8x3 hypercsr\n" + "------------------------------------------------\n" + " row col val\n" + "0 100 0 [0, 0, 0]\n" + "1 200 1 [1, -1, 2]\n" + "... ... ... ..." + ) + html_printer(A, "A") + assert repr_html(A) == ( + "
" + f"{CSS_STYLE}" + '
A
\n' + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" * 2 + " \n" + " \n" + " \n" + "
gb.Matrix
nvals
nrows
ncols
dtype
format
51000int8x3hypercsr
\n" + "
\n" + "
\n" + "\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" * 3 + " \n" + " \n" + "
rowcolval
01000[0, 0, 0]
12001[1, -1, 2]
......
\n" + "
" + ) + + def test_empty(): v = Vector(int, 0) repr_printer(v, "v") diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 622b7e389..c1efcdb70 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -3999,6 +3999,31 @@ def test_ss_iteration(A): assert next(A.ss.iteritems()) is not None +@pytest.mark.skipif("not suitesparse") +def test_ss_iteration_udt(): + # A UDT has no iterator getter of its own type; its values come from the UDT getter + rec = dtypes.register_anonymous( + np.dtype([("it_x", np.int16), ("it_y", np.float32)]), "IterRecord" + ) + pair = dtypes.register_anonymous(np.dtype((np.uint32, (2,))), "IterPair") + for udt, values in [(rec, [(1, 0.5), (-2, 1.5), (3, -2.5)]), (pair, [(1, 2), (3, 4), (5, 6)])]: + A = Matrix.from_coo( + [0, 1, 2], [2, 0, 1], np.array(values, dtype=udt.np_type), dtype=udt, nrows=3, ncols=3 + ) + rows, cols, expected = A.to_coo() + assert [x.tolist() for x in A.ss.itervalues()] == expected.tolist() + r, c, vals = zip(*A.ss.iteritems(), strict=True) + assert list(r) == rows.tolist() + assert list(c) == cols.tolist() + assert [x.tolist() for x in vals] == expected.tolist() + # head of fewer than nvals reads with the iterator + head_rows, head_cols, head_values = A.ss.head(2, sort=True) + assert_array_equal(head_rows, rows[:2]) + assert_array_equal(head_cols, cols[:2]) + assert head_values.dtype == expected.dtype + assert_array_equal(head_values, expected[:2]) + + @pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_udt(): diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index deac8cc85..0bfa80b21 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -2832,6 +2832,27 @@ def test_ss_iteration(v): assert next(v.ss.iteritems()) is not None +@pytest.mark.skipif("not suitesparse") +def test_ss_iteration_udt(): + # A UDT has no iterator getter of its own type; its values come from the UDT getter + rec = dtypes.register_anonymous( + np.dtype([("it_x", np.int16), ("it_y", np.float32)]), "IterRecord" + ) + pair = dtypes.register_anonymous(np.dtype((np.uint32, (2,))), "IterPair") + for udt, values in [(rec, [(1, 0.5), (-2, 1.5), (3, -2.5)]), (pair, [(1, 2), (3, 4), (5, 6)])]: + v = Vector.from_coo([1, 4, 7], np.array(values, dtype=udt.np_type), dtype=udt, size=9) + indices, expected = v.to_coo() + assert [x.tolist() for x in v.ss.itervalues()] == expected.tolist() + keys, vals = zip(*v.ss.iteritems(), strict=True) + assert list(keys) == indices.tolist() + assert [x.tolist() for x in vals] == expected.tolist() + # head of fewer than nvals reads with the iterator + head_indices, head_values = v.ss.head(2, sort=True) + assert_array_equal(head_indices, indices[:2]) + assert head_values.dtype == expected.dtype + assert_array_equal(head_values, expected[:2]) + + def test_broadcasting(A, v): # Vector on left expected = A.dup() From cefe8daae9dadd310a9dd459c8f2ba3ff34bbe43 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Thu, 22 Oct 2026 10:23:08 -0500 Subject: [PATCH 14/19] Cut integer labels at display.max_colwidth as pandas does Behavior fix, in the hand renderer only. pandas formats integer row and column labels with a sign column, makes them one width, cuts any longer than display.max_colwidth (when above 3) to max_colwidth - 3 characters plus "...", and then trims the leading spaces they all share. The hand renderer padded them without the cut, so labels wider than max_colwidth showed whole, in the text and in every HTML label cell: A = Matrix(int, 10**6, 10**6) A[:, :] << 1 with pd.option_context("display.max_colwidth", 6, "display.max_rows", 4, "display.min_rows", 4, "display.max_columns", 4): repr(A) # main (pandas): # 0 1 ... 99... 99... # 0 1 1 ... 1 1 # ... # 99... 1 1 ... 1 1 # before: # 0 1 ... 999998 999999 # 0 1 1 ... 1 1 # ... # 999999 1 1 ... 1 1 _format_labels now formats integer labels the way pandas does, and the text's row labels go through it as the HTML's already did. The text's row-label column is also padded to one width, so the "..." separator of a Matrix with truncated rows keeps all three dots when max_colwidth is 3 or less, as in pandas. The same labels caused the difference at a max_colwidth of 3 or less that "Render rich reprs without pandas, byte-identical to pandas output" documents and "Pin two reprs that deliberately differ from pandas' output" pins: pandas narrows them to max_colwidth there without cutting. That difference is gone too, so the output now matches pandas at every max_colwidth, and that pin now expects pandas' output. display.notebook_repr_html is the one documented difference left. --- graphblas/core/formatting.py | 17 ++--- graphblas/tests/test_formatting.py | 99 ++++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/graphblas/core/formatting.py b/graphblas/core/formatting.py index 91ffdab11..7ef63686e 100644 --- a/graphblas/core/formatting.py +++ b/graphblas/core/formatting.py @@ -540,13 +540,14 @@ def just(x): def _format_labels(labels): # Reproduce pandas Index._format_flat(include_name=False) for our label types: - # integer labels are padded to a uniform width (left-justified, with a sign - # column when any are negative); string labels are left as-is. + # integer labels get a sign column and are made one width, left-justified and cut + # short at display.max_colwidth, then the leading spaces they all share are + # trimmed; string labels are left as-is. if labels and all(isinstance(x, (int, np.integer)) and not isinstance(x, bool) for x in labels): - pattern = "{: d}" if any(x < 0 for x in labels) else "{:d}" - strs = [pattern.format(x) for x in labels] - width = max(len(s) for s in strs) - return [s.ljust(width) for s in strs] + strs = _make_fixed_width([f"{x: d}" for x in labels], "left") + while all(s.startswith(" ") for s in strs): + strs = [s[1:] for s in strs] + return strs return [str(x) for x in labels] @@ -719,7 +720,9 @@ def _get_body_strcols(self): return strcols def _get_index_strcol(self): - idx = _make_fixed_width([str(x) for x in self.tr_frame.index], justify="left") + idx = _make_fixed_width(_format_labels(self.tr_frame.index), justify="left") + width = max(map(len, idx), default=0) + idx = [s.ljust(width) for s in idx] corner = "" if self.frame.col_name is None else str(self.frame.col_name) return [corner, *idx] diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 18f376038..db1dfc03c 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -4144,9 +4144,7 @@ def test_repr_max_colwidth(): "0 100 1.00...\n" "1 161 -5.00..." ) - # Deliberately not pandas' output: with a max_colwidth of 3 or less pandas narrows - # the column labels as well, so labels of one and two digits end up ragged; here - # they keep one padded width. + # With a max_colwidth of 3 or less, integer labels are narrowed but not cut v = Vector.from_coo([0, 1, 11], [1, 2, 3], size=12, name="v") with display_options("display.max_colwidth", 2): repr_printer(v, "v") @@ -4154,8 +4152,99 @@ def test_repr_max_colwidth(): '"v" nvals size dtype format\n' "gb.Vector 3 12 INT64 bitmap\n" "-------------------------------------\n" - "index 0 1 2 3 4 5 6 7 8 9 10 11\n" - "value 1 2 3" + "index 0 1 2 3 4 5 6 7 8 9 10 11\n" + "value 1 2 3" + ) + # Longer integer labels are cut short as cells are, counting the sign column pandas + # gives them, in the text and the HTML + A = Matrix(int, 10**6, 10**6, name="A") + A[:, :] << 1 + small = ("display.max_rows", 4, "display.min_rows", 4, "display.max_columns", 4) + with display_options("display.max_colwidth", 6, *small): + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 1000000000000 1000000 1000000 INT64 fullr (iso)\n" + "--------------------------------------------------------------\n" + " 0 1 ... 99... 99...\n" + "0 1 1 ... 1 1\n" + "1 1 1 ... 1 1\n" + "... ... ... ... ... ...\n" + "99... 1 1 ... 1 1\n" + "99... 1 1 ... 1 1" + ) + html_printer(A, "A") + assert repr_html(A) == ( + "
" + f"{CSS_STYLE}" + '
A
\n' + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" * 2 + " \n" + " \n" + " \n" + "
gb.Matrix
nvals
nrows
ncols
dtype
format
10000000000001000000INT64fullr (iso)
\n" + "
\n" + "
\n" + "\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" * 2 + " \n" + " \n" + " \n" + " \n" + " \n" + + " \n" * 2 + + " \n" + + " \n" * 2 + + " \n" + " \n" + " \n" + + " \n" * 2 + + " \n" + + " \n" * 2 + + " \n" + " \n" + " \n" + " \n" * 5 + " \n" + " \n" + " \n" + + " \n" * 2 + + " \n" + + " \n" * 2 + + " \n" + " \n" + " \n" + + " \n" * 2 + + " \n" + + " \n" * 2 + + " \n" + " \n" + "
01...99...
01...1
11...1
......
99...1...1
99...1...1
\n" + "
" ) From 9df389ca0e6afd4fe1531da851faacd3c3add598 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 27 Oct 2026 23:28:55 -0500 Subject: [PATCH 15/19] Test that the row labels of a cut-short repr stay padded to one width The "..." label of truncated rows keeps all three dots only because _get_index_strcol pads every row label to the widest one; no test failed with that padding removed. --- graphblas/tests/test_formatting.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index db1dfc03c..eec12f629 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -4155,11 +4155,29 @@ def test_repr_max_colwidth(): "index 0 1 2 3 4 5 6 7 8 9 10 11\n" "value 1 2 3" ) + # The row labels are still padded to one width, so the "..." label of truncated rows + # keeps all three dots + A = Matrix.from_coo( + [0, 9999, 9999], [9999, 1, 9999], [1, 2, 3], nrows=10000, ncols=10000, name="A" + ) + small = ("display.max_rows", 4, "display.min_rows", 4, "display.max_columns", 4) + with display_options("display.max_colwidth", 3, *small): + repr_printer(A, "A") + assert repr(A) == ( + '"A" nvals nrows ncols dtype format\n' + "gb.Matrix 3 10000 10000 INT64 hypercsr\n" + "-----------------------------------------------\n" + " 0 1 ... 9998 9999\n" + "0 ... 1 \n" + "1 ... \n" + "... .. .. ... .. .. \n" + "9998 ... \n" + "9999 2 ... 3 " + ) # Longer integer labels are cut short as cells are, counting the sign column pandas # gives them, in the text and the HTML A = Matrix(int, 10**6, 10**6, name="A") A[:, :] << 1 - small = ("display.max_rows", 4, "display.min_rows", 4, "display.max_columns", 4) with display_options("display.max_colwidth", 6, *small): repr_printer(A, "A") assert repr(A) == ( From 154c3a8e87c017faad432d4b93c5de2f17ba8d38 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 28 Oct 2026 04:25:39 -0500 Subject: [PATCH 16/19] Make the dense Matrix in a repr test INT64 on Windows too test_repr_max_rows_below_min_rows built its dense Matrix from np.arange, whose integers are int32 by default on Windows with numpy before 2, so its repr said INT32 there and the test failed (windows-latest, numpy 1.25). The renderer is right; give the array an int64 dtype. --- graphblas/tests/test_formatting.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index eec12f629..2d2714449 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -4624,8 +4624,9 @@ def test_repr_max_rows_below_min_rows(): ".. .. .. ..\n" "2 3" ) - # A dense Matrix stays a grid: the COO form would show no more rows than the grid - vals = np.arange(5)[:, None] * 1000 + np.arange(4)[None, :] + 1 + # A dense Matrix stays a grid: the COO form would show no more rows than the grid. + # INT64 on Windows too, where numpy before 2 makes integers int32 by default. + vals = np.arange(5, dtype=np.int64)[:, None] * 1000 + np.arange(4)[None, :] + 1 D = Matrix.from_dense(vals, name="D") with display_options("display.max_rows", 3, "display.max_columns", 2): repr_printer(D, "D") From a9ff1ffc98baf3a9c42cc3a3bcab42cf81873ed3 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 28 Oct 2026 23:04:04 -0500 Subject: [PATCH 17/19] Show up to 151 rows or 150 columns for a display option of None in a repr Behavior change. pandas allows None for display.max_rows, display.min_rows and display.max_columns ("no limit"), and repr and _repr_html_ of a Matrix or Vector raised TypeError for each of them, in reading the values to show or in choosing the COO form: A = Matrix.from_coo([0, 1], [0, 1], [1, 2], nrows=100, ncols=3) with pd.option_context("display.max_rows", None): repr(A) # was TypeError: '<=' not supported between instances # of 'int' and 'NoneType'; now shows all 100 rows A repr cannot read every row of a Matrix with 2**60 rows, so None now shows a frame of up to 151 rows or 150 columns whole, and the first and last 75 rows or columns of a larger one. 150 is what a max_columns of 0 already reads for the renderer to fit to the terminal; it bounds a grid at 150 by 150 values, which renders in about 20 ms. In pandas' terms: - max_rows=None is max_rows=151 with min_rows=150, whatever min_rows is (pandas ignores min_rows when max_rows is None). The extra row lets the COO form show 150 entries and its "..." row: with a max_rows of 150 the renderer would cut that 151-row frame again, as it does whenever min_rows is at least max_rows. - min_rows=None is max_rows, as pandas documents. - max_columns=None is max_columns=150, and a wide frame wraps at display.width, as with any max_columns above 0. The renderer and the code that reads the values take these limits from one place, so the rows and columns read are the ones shown. --- graphblas/core/formatting.py | 71 +++++++++++++++++---------- graphblas/tests/test_formatting.py | 78 ++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 26 deletions(-) diff --git a/graphblas/core/formatting.py b/graphblas/core/formatting.py index 7ef63686e..32be8a89a 100644 --- a/graphblas/core/formatting.py +++ b/graphblas/core/formatting.py @@ -245,13 +245,48 @@ def _update_vector_array(arr, vector, columns, column_offset, *, mask=None): arr[0, cols[np.isnan(vals)]] = "nan" -def _get_max_columns(): +# The most rows or columns a repr reads when the display options set no limit: a max_columns of +# 0 (as many as fit the terminal, which the renderer then cuts to its width) or None. pandas +# takes None as no limit at all, but a repr cannot read every row of a Matrix with 2**60. +_NO_LIMIT = 150 + + +def _display_limits(): + """display.max_rows, min_rows and max_columns as the renderer uses them, None capped. + + A max_rows of None reads as ``_NO_LIMIT + 1`` with a min_rows of ``_NO_LIMIT``, whatever + min_rows is (pandas ignores it then): a longer frame shows its first and last + ``_NO_LIMIT // 2`` rows, and a COO form its first ``_NO_LIMIT`` entries and the "..." row, + which a max_rows of ``_NO_LIMIT`` would cut again. A min_rows of None is max_rows, as in + pandas, and a max_columns of None is ``_NO_LIMIT``. + """ + max_rows = _display_option("max_rows") + min_rows = _display_option("min_rows") max_columns = _display_option("max_columns") - if max_columns == 0: - # We are probably in a terminal and pandas will automatically size the data correctly. - # In this case, let's get a sufficiently large amount of data to show and defer to pandas. - max_columns = 150 - return max_columns + if max_rows is None: + max_rows = _NO_LIMIT + 1 + min_rows = _NO_LIMIT + elif min_rows is None: + min_rows = max_rows + if max_columns is None: + max_columns = _NO_LIMIT + return max_rows, min_rows, max_columns + + +def _get_limits(max_rows, min_rows, max_columns): + """The limits of the rows and columns to read: the display options for any not given.""" + display_max_rows, display_min_rows, display_max_columns = _display_limits() + if max_rows is None: # pragma: no branch + max_rows = display_max_rows + if min_rows is None: # pragma: no branch + min_rows = display_min_rows + if max_columns is None: # pragma: no branch + max_columns = display_max_columns + if max_columns == 0: + # We are probably in a terminal and the renderer will size the data to fit it. + # In this case, let's get a sufficiently large amount of data to show. + max_columns = _NO_LIMIT + return max_rows, min_rows, max_columns def _terminal_rows(): @@ -814,9 +849,8 @@ def to_string(self, line_width): def _render_text(frame): - max_cols = _display_option("max_columns") line_width = _console_width() if _display_option("expand_frame_repr") else None - fmt = _TextFormatter(frame, _display_option("max_rows"), _display_option("min_rows"), max_cols) + fmt = _TextFormatter(frame, *_display_limits()) return fmt.to_string(line_width) @@ -916,22 +950,12 @@ def render(self): def _render_html(frame): - fmt = _TextFormatter( - frame, - _display_option("max_rows"), - _display_option("min_rows"), - _display_option("max_columns"), - ) + fmt = _TextFormatter(frame, *_display_limits()) return _HtmlBuilder(fmt).render() def _get_matrix_dataframe(matrix, max_rows, min_rows, max_columns, *, mask=None): - if max_rows is None: # pragma: no branch - max_rows = _display_option("max_rows") - if min_rows is None: # pragma: no branch - min_rows = _display_option("min_rows") - if max_columns is None: # pragma: no branch - max_columns = _get_max_columns() + max_rows, min_rows, max_columns = _get_limits(max_rows, min_rows, max_columns) if max_rows == 0: # The renderer fits the rows to the terminal; read at least the first row fit_rows = max(_terminal_rows(), 1) @@ -995,12 +1019,7 @@ def _get_matrix_dataframe(matrix, max_rows, min_rows, max_columns, *, mask=None) def _get_vector_dataframe(vector, max_rows, min_rows, max_columns, *, mask=None): - if max_rows is None: # pragma: no branch - max_rows = _display_option("max_rows") - if min_rows is None: # pragma: no branch - min_rows = _display_option("min_rows") - if max_columns is None: # pragma: no branch - max_columns = _get_max_columns() + max_rows, min_rows, max_columns = _get_limits(max_rows, min_rows, max_columns) columns, column_groups = _get_chunk(vector._size, max_columns, max_columns) arr = np.full((1, len(columns)), np.nan, dtype=object) for column_group, column_offset in column_groups: diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 2d2714449..bb791f749 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -4641,6 +4641,84 @@ def test_repr_max_rows_below_min_rows(): ) +@pytest.mark.parametrize("use_pandas", [True, False]) +def test_repr_display_options_none(monkeypatch, use_pandas): + # pandas takes None for max_rows, min_rows and max_columns as no limit. A repr shows up + # to 150 rows or columns instead, and the first and last 75 of more: max_rows=None is + # max_rows=151 with min_rows=150, so the COO form can show 150 entries and its "..." row. + # A min_rows of None is max_rows, as in pandas. Before, each raised TypeError. + if use_pandas and pd is None: # pragma: no cover (no pandas) + pytest.skip("needs pandas") + if not use_pandas: + monkeypatch.setattr(formatting, "has_pandas", False) + + def lines(x): + return repr(x).split("\n")[3:] # skip the info header + + A = Matrix.from_coo([0, 1], [0, 1], [1, 2], nrows=100, ncols=3, name="A") + with display_options("display.max_rows", None): + assert len(lines(A)) == 1 + 100 # every row, though min_rows is 10 + # Past 151 rows, the rows shown are read, and the rest are not shown + A = Matrix.from_dense(np.arange(152 * 2).reshape(152, 2), name="A") + with display_options("display.max_rows", None): + rows = lines(A) + assert len(rows) == 1 + 75 + 1 + 75 + assert rows[1].split() == ["0", "0", "1"] + assert rows[75].split() == ["74", "148", "149"] + assert rows[76].split() == ["..", "...", "..."] + assert rows[77].split() == ["77", "154", "155"] + assert rows[-1].split() == ["151", "302", "303"] + text, html = repr(A), repr_html(A) + with display_options("display.max_rows", 151, "display.min_rows", 150): + assert repr(A) == text + assert repr_html(A) == html + with display_options("display.max_rows", None): + assert len(lines(A[:151, :].new(name="A"))) == 1 + 151 + # The COO form of more entries than 151 + v = Vector.from_coo(np.arange(0, 10000, 10), np.arange(1000), size=10000, name="v") + with display_options("display.max_rows", None): + rows = lines(v) + assert len(rows) == 1 + 150 + 1 + assert rows[-2].split() == ["149", "1490", "149"] + assert rows[-1].split() == ["...", "...", "..."] + + # The columns shown: 0 to 74 and 125 to 199 + expected = [*map(str, range(75)), "...", *map(str, range(125, 200))] + B = Matrix.from_dense(np.arange(2 * 200).reshape(2, 200), name="B") + with display_options("display.max_columns", None, "display.expand_frame_repr", False): + assert lines(B)[0].split() == expected + html = repr_html(B) + header = html[html.index('class="dataframe"') :].split("")[0] + assert header.count("") == 1 + 151 + text = repr(B) + with display_options("display.max_columns", 150, "display.expand_frame_repr", False): + assert repr(B) == text + # As with any max_columns, a wide frame wraps at display.width + with display_options("display.max_columns", None, "display.width", 80): + assert len(lines(B)) > 3 + + # min_rows=None is max_rows, as pandas has it, for the grid and the COO form + w = Vector.from_coo(np.arange(0, 10000, 100), np.arange(100), size=10000, name="w") + for x in [A, w]: + with display_options("display.max_rows", 6, "display.min_rows", None): + text, html = repr(x), repr_html(x) + with display_options("display.max_rows", 6, "display.min_rows", 6): + assert repr(x) == text + assert repr_html(x) == html + + # A Matrix too large to read whole + C = Matrix(int, 2**60, 2**60, name="C") + C[0, 0] = 1 + C[2**60 - 1, 2**60 - 1] = 2 + options = ("display.max_rows", None, "display.min_rows", None, "display.max_columns", None) + with display_options(*options, "display.expand_frame_repr", False): + rows = lines(C) + assert len(rows) == 1 + 75 + 1 + 75 + assert rows[0].split()[75] == "..." + assert rows[1].split()[:2] == ["0", "1"] + assert rows[-1].split()[-1] == "2" + + def test_repr_max_columns_one(): # With room for a single column, the first column is shown instead of both ends A = Matrix.from_coo( From 676c87287728b1e08e097b7d9798bd60a9e33243 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 4 Nov 2026 10:22:20 -0800 Subject: [PATCH 18/19] Expect complex values in the repr tests only where SuiteSparse has complex types psg 7.4.0 to 7.4.3.0 on Windows is built without complex types, so dtypes._supports_complex is False and a complex literal registers as an anonymous UDT named dtype('complex128'). Its values render the same, but the header's dtype cell says dtype('complex128'), not FC64, so four repr tests from this PR failed there: test_sparse_complex_repr, test_sparse_repr_chop_threshold, test_repr_float_format and test_repr_max_rows_zero (i605w). test_sparse_complex_repr now skips without complex types, as the other complex tests do; the chop-threshold and float-format tests stop before their complex cases; and the max_rows=0 test, which is about rows, not complex, uses FP64 values. --- graphblas/tests/test_formatting.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index bb791f749..98518cd4b 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -3822,6 +3822,7 @@ def test_sparse_float_repr_scientific(): ) +@pytest.mark.skipif("not dtypes._supports_complex") def test_sparse_complex_repr(): values = [ 1 + 2j, @@ -3986,6 +3987,8 @@ def test_sparse_repr_chop_threshold(): "0 100 1.50\n" "1 161 -0.25" ) + if not dtypes._supports_complex: + return # A complex value would be a UDT, with its own dtype in the header # A complex value is compared by its magnitude, so a small part is not chopped v = Vector.from_coo([100, 161], [1e-8 + 1j, 2 - 1e-9j], size=1000, name="v") with display_options("display.chop_threshold", 1e-6): @@ -4025,6 +4028,8 @@ def test_repr_float_format(): "index 0 1 2 3\n" "value 1,234.50 nan -0.00" ) + if not dtypes._supports_complex: + return # A complex value would be a UDT, with its own dtype in the header v = Vector.from_coo([100, 161], [1 + 2j, complex(np.nan, 1)], size=1000, name="v") with display_options("display.float_format", "{:.2f}".format): repr_printer(v, "v") @@ -4509,13 +4514,13 @@ def test_repr_max_rows_zero(monkeypatch): ) # A terminal with room for one row leaves none to show. main's pandas renderer # raised TypeError here (pandas fails on a frame truncated to no rows). - v = Vector.from_coo([100, 161, 222], [1 + 2j, -1j, 2.5], size=1000, name="v") + v = Vector.from_coo([100, 161, 222], [1.5, -1.0, 2.5], size=1000, name="v") monkeypatch.setenv("LINES", "4") with display_options("display.max_rows", 0): repr_printer(v, "v") assert repr(v) == ( '"v" nvals size dtype format\n' - "gb.Vector 3 1000 FC64 sparse\n" + "gb.Vector 3 1000 FP64 sparse\n" "-------------------------------------\n" " index val\n" ".. ... ..." From b7bcbf4078bc5ef6929c37aa41afce20b43e49f7 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 4 Nov 2026 10:27:04 -0800 Subject: [PATCH 19/19] Test the complex repr cases on their own, skipped without complex types The chop-threshold and float-format tests ended early on a build without complex types, and those return lines went uncovered on every other build. As separate tests with the skip of test_sparse_complex_repr, every line runs wherever complex types exist, and nothing else changes. --- graphblas/tests/test_formatting.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 98518cd4b..3da1297d4 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -3987,8 +3987,10 @@ def test_sparse_repr_chop_threshold(): "0 100 1.50\n" "1 161 -0.25" ) - if not dtypes._supports_complex: - return # A complex value would be a UDT, with its own dtype in the header + + +@pytest.mark.skipif("not dtypes._supports_complex") +def test_sparse_repr_chop_threshold_complex(): # A complex value is compared by its magnitude, so a small part is not chopped v = Vector.from_coo([100, 161], [1e-8 + 1j, 2 - 1e-9j], size=1000, name="v") with display_options("display.chop_threshold", 1e-6): @@ -4028,8 +4030,10 @@ def test_repr_float_format(): "index 0 1 2 3\n" "value 1,234.50 nan -0.00" ) - if not dtypes._supports_complex: - return # A complex value would be a UDT, with its own dtype in the header + + +@pytest.mark.skipif("not dtypes._supports_complex") +def test_repr_float_format_complex(): v = Vector.from_coo([100, 161], [1 + 2j, complex(np.nan, 1)], size=1000, name="v") with display_options("display.float_format", "{:.2f}".format): repr_printer(v, "v")