From 06735adc6df3e11fe7eb22c2ed2e485cea71f98f Mon Sep 17 00:00:00 2001 From: Faisal Dosani Date: Sat, 1 Aug 2026 11:48:39 -0300 Subject: [PATCH] fix: size report table columns to their content --- datacompy/base.py | 78 ++++++++++++++++ datacompy/templates/report_template.j2 | 45 +++++----- tests/snapshots/pandas_duplicates.txt | 6 +- tests/snapshots/pandas_no_mismatches.txt | 6 +- tests/snapshots/pandas_on_index.txt | 12 +-- tests/snapshots/pandas_sample_count_zero.txt | 12 +-- tests/snapshots/pandas_unique_columns.txt | 6 +- tests/snapshots/pandas_unique_rows.txt | 6 +- tests/snapshots/pandas_with_mismatches.txt | 14 +-- tests/snapshots/pandas_with_tolerances.txt | 6 +- tests/snapshots/polars_no_mismatches.txt | 6 +- tests/snapshots/polars_unique_columns.txt | 6 +- tests/snapshots/polars_unique_rows.txt | 6 +- tests/snapshots/polars_with_mismatches.txt | 14 +-- tests/test_base.py | 29 ++++++ tests/test_report.py | 94 ++++++++++++++++++++ 16 files changed, 275 insertions(+), 71 deletions(-) diff --git a/datacompy/base.py b/datacompy/base.py index 40883e2b..aeddf12a 100644 --- a/datacompy/base.py +++ b/datacompy/base.py @@ -24,6 +24,7 @@ import logging from abc import ABC, abstractmethod from collections import Counter +from collections.abc import Sequence from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, TypedDict @@ -532,6 +533,79 @@ def _resolve_template_path(template_name: str) -> tuple[str, str]: ) +#: Spaces between adjacent columns of a report table. +TABLE_GUTTER = " " + + +def fixed_width_table( + headers: Sequence[str], + rows: Sequence[Sequence[Any]], + align: str = "", +) -> str: + """Lay out a plain text table, sizing every column to its widest cell. + + Exposed to templates as ``fixed_width_table``. Cells are stringified but not + otherwise formatted, so the template stays in charge of how a number is + presented and this function stays in charge only of how wide the column is. + That split is what keeps the header rule, the separator, and the body + aligned no matter how long a dataset name or dtype turns out to be. + + Parameters + ---------- + headers : sequence of str + Column headings. Also the minimum width of each column. + rows : sequence of sequence + Body rows. Each row must have one cell per heading. + align : str, optional + One character per column, ``"l"`` for left and ``"r"`` for right. + Columns beyond the end of the string, and an omitted *align*, default + to left. + + Returns + ------- + str + The heading row, a separator row of dashes, and one row per entry, + newline separated and free of trailing whitespace. + + Raises + ------ + ValueError + If a row does not have one cell per heading. + + Examples + -------- + >>> print(fixed_width_table(["Name", "N"], [["ab", 1], ["cdefg", 22]], "lr")) + Name N + ----- -- + ab 1 + cdefg 22 + """ + body = [[str(cell) for cell in row] for row in rows] + for index, row in enumerate(body): + if len(row) != len(headers): + raise ValueError( + f"row {index} has {len(row)} cells but there are {len(headers)} headers" + ) + + widths = [ + max(len(header), *(len(row[column]) for row in body)) if body else len(header) + for column, header in enumerate(headers) + ] + + def lay_out(cells: Sequence[str]) -> str: + padded = [ + cell.rjust(width) + if align[column : column + 1] == "r" + else cell.ljust(width) + for column, (cell, width) in enumerate(zip(cells, widths, strict=True)) + ] + return TABLE_GUTTER.join(padded).rstrip() + + lines = [lay_out(headers), TABLE_GUTTER.join("-" * width for width in widths)] + lines.extend(lay_out(row) for row in body) + return "\n".join(lines) + + def render(template_name: str, **context: Any) -> str: """Render a template using Jinja2. @@ -563,7 +637,11 @@ def render(template_name: str, **context: Any) -> str: autoescape=select_autoescape(), trim_blocks=True, lstrip_blocks=True, + # ``do`` lets the template assemble table rows in a loop, so cell + # formatting stays next to the table it belongs to. + extensions=["jinja2.ext.do"], ) + env.globals["fixed_width_table"] = fixed_width_table template = env.get_template(template_file) return template.render(**context).strip() diff --git a/datacompy/templates/report_template.j2 b/datacompy/templates/report_template.j2 index 7eb03e2c..84e976de 100644 --- a/datacompy/templates/report_template.j2 +++ b/datacompy/templates/report_template.j2 @@ -4,10 +4,11 @@ DataComPy Comparison DataFrame Summary ----------------- -DataFrame Columns Rows ------------------------- -{{ '%-10s %8d %5d'|format(df1_name, df1_shape[1], df1_shape[0]) }} -{{ '%-10s %8d %5d'|format(df2_name, df2_shape[1], df2_shape[0]) }} +{{ fixed_width_table( + ['DataFrame', 'Columns', 'Rows'], + [[df1_name, '{:,}'.format(df1_shape[1]), '{:,}'.format(df1_shape[0])], + [df2_name, '{:,}'.format(df2_shape[1]), '{:,}'.format(df2_shape[0])]], + 'lrr') }} Column Summary @@ -42,27 +43,29 @@ Total number of values which compare unequal: {{ "{:,}".format(column_comparison Columns with Unequal Values or Types ------------------------------------ -{% set headers = ['Column', - mismatch_stats.df1_name + ' dtype', - mismatch_stats.df2_name + ' dtype', - '# Unequal', - 'Max Diff', - '# Null Diff', - 'Rel Tol', - 'Abs Tol'] %} -{{ '%-20s %-15s %-15s %10s %10s %12s %10s %10s'|format(headers[0], headers[1], headers[2], headers[3], headers[4], headers[5], headers[6], headers[7]) }} -{{ '-' * 20 }} {{ '-' * 15 }} {{ '-' * 15 }} {{ '-' * 10 }} {{ '-' * 10 }} {{ '-' * 12 }} {{ '-' * 10 }} {{ '-' * 10 }} +{% set stat_rows = [] %} {% for stat in mismatch_stats.stats %} -{{ '%-20s %-15s %-15s %10d %10.4f %12d %10.4f %10.4f'|format( - stat.column[:18] + ('...' if stat.column|length > 18 else ''), +{% do stat_rows.append([ + stat.column if stat.column|length <= 20 else stat.column[:17] ~ '...', stat.dtype1, stat.dtype2, - stat.unequal_cnt, - stat.max_diff, - stat.null_diff, - stat.rel_tol, - stat.abs_tol) }} + '{:,}'.format(stat.unequal_cnt), + '%.4f'|format(stat.max_diff), + '{:,}'.format(stat.null_diff), + '%.4f'|format(stat.rel_tol), + '%.4f'|format(stat.abs_tol)]) %} {% endfor %} +{{ fixed_width_table( + ['Column', + mismatch_stats.df1_name + ' dtype', + mismatch_stats.df2_name + ' dtype', + '# Unequal', + 'Max Diff', + '# Null Diff', + 'Rel Tol', + 'Abs Tol'], + stat_rows, + 'lllrrrrr') }} {% if mismatch_stats.has_samples %} diff --git a/tests/snapshots/pandas_duplicates.txt b/tests/snapshots/pandas_duplicates.txt index 8a5d1168..262b7f7e 100644 --- a/tests/snapshots/pandas_duplicates.txt +++ b/tests/snapshots/pandas_duplicates.txt @@ -5,9 +5,9 @@ DataFrame Summary ----------------- DataFrame Columns Rows ------------------------- -df1 2 3 -df2 2 3 +--------- ------- ---- +df1 2 3 +df2 2 3 Column Summary diff --git a/tests/snapshots/pandas_no_mismatches.txt b/tests/snapshots/pandas_no_mismatches.txt index 0269ec5a..f42a7996 100644 --- a/tests/snapshots/pandas_no_mismatches.txt +++ b/tests/snapshots/pandas_no_mismatches.txt @@ -5,9 +5,9 @@ DataFrame Summary ----------------- DataFrame Columns Rows ------------------------- -left 3 3 -right 3 3 +--------- ------- ---- +left 3 3 +right 3 3 Column Summary diff --git a/tests/snapshots/pandas_on_index.txt b/tests/snapshots/pandas_on_index.txt index c3e596d4..9f33fc86 100644 --- a/tests/snapshots/pandas_on_index.txt +++ b/tests/snapshots/pandas_on_index.txt @@ -5,9 +5,9 @@ DataFrame Summary ----------------- DataFrame Columns Rows ------------------------- -df1 1 3 -df2 1 3 +--------- ------- ---- +df1 1 3 +df2 1 3 Column Summary @@ -41,9 +41,9 @@ Total number of values which compare unequal: 1 Columns with Unequal Values or Types ------------------------------------ -Column df1 dtype df2 dtype # Unequal Max Diff # Null Diff Rel Tol Abs Tol --------------------- --------------- --------------- ---------- ---------- ------------ ---------- ---------- -val int64 int64 1 79.0000 0 0.0000 0.0000 +Column df1 dtype df2 dtype # Unequal Max Diff # Null Diff Rel Tol Abs Tol +------ --------- --------- --------- -------- ----------- ------- ------- +val int64 int64 1 79.0000 0 0.0000 0.0000 Sample Rows with Unequal Values diff --git a/tests/snapshots/pandas_sample_count_zero.txt b/tests/snapshots/pandas_sample_count_zero.txt index 1fa23ab8..200d256b 100644 --- a/tests/snapshots/pandas_sample_count_zero.txt +++ b/tests/snapshots/pandas_sample_count_zero.txt @@ -5,9 +5,9 @@ DataFrame Summary ----------------- DataFrame Columns Rows ------------------------- -df1 2 3 -df2 2 3 +--------- ------- ---- +df1 2 3 +df2 2 3 Column Summary @@ -41,6 +41,6 @@ Total number of values which compare unequal: 3 Columns with Unequal Values or Types ------------------------------------ -Column df1 dtype df2 dtype # Unequal Max Diff # Null Diff Rel Tol Abs Tol --------------------- --------------- --------------- ---------- ---------- ------------ ---------- ---------- -val int64 int64 3 3.0000 0 0.0000 0.0000 \ No newline at end of file +Column df1 dtype df2 dtype # Unequal Max Diff # Null Diff Rel Tol Abs Tol +------ --------- --------- --------- -------- ----------- ------- ------- +val int64 int64 3 3.0000 0 0.0000 0.0000 \ No newline at end of file diff --git a/tests/snapshots/pandas_unique_columns.txt b/tests/snapshots/pandas_unique_columns.txt index 6f69fc13..7852de8c 100644 --- a/tests/snapshots/pandas_unique_columns.txt +++ b/tests/snapshots/pandas_unique_columns.txt @@ -5,9 +5,9 @@ DataFrame Summary ----------------- DataFrame Columns Rows ------------------------- -df1 3 2 -df2 3 2 +--------- ------- ---- +df1 3 2 +df2 3 2 Column Summary diff --git a/tests/snapshots/pandas_unique_rows.txt b/tests/snapshots/pandas_unique_rows.txt index a489b095..a5cff3cb 100644 --- a/tests/snapshots/pandas_unique_rows.txt +++ b/tests/snapshots/pandas_unique_rows.txt @@ -5,9 +5,9 @@ DataFrame Summary ----------------- DataFrame Columns Rows ------------------------- -df1 2 3 -df2 2 3 +--------- ------- ---- +df1 2 3 +df2 2 3 Column Summary diff --git a/tests/snapshots/pandas_with_mismatches.txt b/tests/snapshots/pandas_with_mismatches.txt index 9e4209d8..21c4770f 100644 --- a/tests/snapshots/pandas_with_mismatches.txt +++ b/tests/snapshots/pandas_with_mismatches.txt @@ -5,9 +5,9 @@ DataFrame Summary ----------------- DataFrame Columns Rows ------------------------- -df1 3 3 -df2 3 3 +--------- ------- ---- +df1 3 3 +df2 3 3 Column Summary @@ -41,10 +41,10 @@ Total number of values which compare unequal: 2 Columns with Unequal Values or Types ------------------------------------ -Column df1 dtype df2 dtype # Unequal Max Diff # Null Diff Rel Tol Abs Tol --------------------- --------------- --------------- ---------- ---------- ------------ ---------- ---------- -score float64 float64 1 0.5000 0 0.0000 0.0000 -val int64 int64 1 79.0000 0 0.0000 0.0000 +Column df1 dtype df2 dtype # Unequal Max Diff # Null Diff Rel Tol Abs Tol +------ --------- --------- --------- -------- ----------- ------- ------- +score float64 float64 1 0.5000 0 0.0000 0.0000 +val int64 int64 1 79.0000 0 0.0000 0.0000 Sample Rows with Unequal Values diff --git a/tests/snapshots/pandas_with_tolerances.txt b/tests/snapshots/pandas_with_tolerances.txt index 1fecc505..fbb1cb13 100644 --- a/tests/snapshots/pandas_with_tolerances.txt +++ b/tests/snapshots/pandas_with_tolerances.txt @@ -5,9 +5,9 @@ DataFrame Summary ----------------- DataFrame Columns Rows ------------------------- -df1 2 2 -df2 2 2 +--------- ------- ---- +df1 2 2 +df2 2 2 Column Summary diff --git a/tests/snapshots/polars_no_mismatches.txt b/tests/snapshots/polars_no_mismatches.txt index 4dd7514b..cf34b137 100644 --- a/tests/snapshots/polars_no_mismatches.txt +++ b/tests/snapshots/polars_no_mismatches.txt @@ -5,9 +5,9 @@ DataFrame Summary ----------------- DataFrame Columns Rows ------------------------- -left 2 3 -right 2 3 +--------- ------- ---- +left 2 3 +right 2 3 Column Summary diff --git a/tests/snapshots/polars_unique_columns.txt b/tests/snapshots/polars_unique_columns.txt index 6f69fc13..7852de8c 100644 --- a/tests/snapshots/polars_unique_columns.txt +++ b/tests/snapshots/polars_unique_columns.txt @@ -5,9 +5,9 @@ DataFrame Summary ----------------- DataFrame Columns Rows ------------------------- -df1 3 2 -df2 3 2 +--------- ------- ---- +df1 3 2 +df2 3 2 Column Summary diff --git a/tests/snapshots/polars_unique_rows.txt b/tests/snapshots/polars_unique_rows.txt index a516f0d5..429d7844 100644 --- a/tests/snapshots/polars_unique_rows.txt +++ b/tests/snapshots/polars_unique_rows.txt @@ -5,9 +5,9 @@ DataFrame Summary ----------------- DataFrame Columns Rows ------------------------- -df1 2 3 -df2 2 3 +--------- ------- ---- +df1 2 3 +df2 2 3 Column Summary diff --git a/tests/snapshots/polars_with_mismatches.txt b/tests/snapshots/polars_with_mismatches.txt index 52f89939..483acaa3 100644 --- a/tests/snapshots/polars_with_mismatches.txt +++ b/tests/snapshots/polars_with_mismatches.txt @@ -5,9 +5,9 @@ DataFrame Summary ----------------- DataFrame Columns Rows ------------------------- -df1 3 3 -df2 3 3 +--------- ------- ---- +df1 3 3 +df2 3 3 Column Summary @@ -41,10 +41,10 @@ Total number of values which compare unequal: 2 Columns with Unequal Values or Types ------------------------------------ -Column df1 dtype df2 dtype # Unequal Max Diff # Null Diff Rel Tol Abs Tol --------------------- --------------- --------------- ---------- ---------- ------------ ---------- ---------- -score Float64 Float64 1 0.5000 0 0.0000 0.0000 -val Int64 Int64 1 79.0000 0 0.0000 0.0000 +Column df1 dtype df2 dtype # Unequal Max Diff # Null Diff Rel Tol Abs Tol +------ --------- --------- --------- -------- ----------- ------- ------- +score Float64 Float64 1 0.5000 0 0.0000 0.0000 +val Int64 Int64 1 79.0000 0 0.0000 0.0000 Sample Rows with Unequal Values diff --git a/tests/test_base.py b/tests/test_base.py index cf5f7fcb..cd314c35 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -32,6 +32,7 @@ BaseCompare, _resolve_template_path, df_to_str, + fixed_width_table, get_column_tolerance, render, save_html_report, @@ -444,3 +445,31 @@ def test_get_column_tolerance_column_is_default(): """Test get_column_tolerance returns the value for 'default' if column is literally 'default'.""" tol_dict = {"default": 0.07} assert get_column_tolerance("default", tol_dict) == pytest.approx(0.07) + + +def test_fixed_width_table_sizes_columns_to_content(): + """Every column is as wide as its widest cell, heading included.""" + table = fixed_width_table(["Name", "N"], [["ab", 1], ["cdefg", 22]], "lr") + assert table == "Name N\n----- --\nab 1\ncdefg 22" + + +def test_fixed_width_table_defaults_to_left_alignment(): + """An omitted or short align string leaves the remaining columns left aligned.""" + assert fixed_width_table(["Name", "N"], [["ab", 1]]) == "Name N\n---- -\nab 1" + + +def test_fixed_width_table_no_rows(): + """A table with no body rows still emits the heading and the separator.""" + assert fixed_width_table(["A", "B"], []) == "A B\n- -" + + +def test_fixed_width_table_rejects_ragged_rows(): + """A row whose cell count disagrees with the headings is a programming error.""" + with pytest.raises(ValueError, match="row 1 has 1 cells but there are 2 headers"): + fixed_width_table(["A", "B"], [["x", "y"], ["z"]]) + + +def test_fixed_width_table_has_no_trailing_whitespace(): + """Trailing padding is stripped so snapshots stay diff friendly.""" + table = fixed_width_table(["Name", "Note"], [["a", "x"], ["bbbb", "y"]], "ll") + assert all(line == line.rstrip() for line in table.splitlines()) diff --git a/tests/test_report.py b/tests/test_report.py index 342bfd1e..4851a7fd 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -343,3 +343,97 @@ def test_report_method_returns_string(): rpt = PandasCompare(df1, df2, "id").report() assert isinstance(rpt, str) assert "DataComPy" in rpt + + +# --------------------------------------------------------------------------- +# Table alignment +# --------------------------------------------------------------------------- + + +def _dash_spans(separator: str) -> list[tuple[int, int]]: + """Return the (start, end) offset of each run of dashes in *separator*.""" + spans, start = [], None + for index, char in enumerate(separator): + if char == "-" and start is None: + start = index + elif char != "-" and start is not None: + spans.append((start, index)) + start = None + if start is not None: + spans.append((start, len(separator))) + return spans + + +def _assert_table_aligned(report: str, heading_prefix: str) -> None: + """Assert every cell of a report table sits inside its own column. + + The separator row of dashes defines the column boundaries. A cell that + overflows its field, which is what a hard coded width does as soon as a name + grows past it, lands in a gutter and fails here. + """ + lines = report.splitlines() + head = next(i for i, line in enumerate(lines) if line.startswith(heading_prefix)) + spans = _dash_spans(lines[head + 1]) + assert len(spans) > 1, ( + f"expected per column dashes under {lines[head]!r}, got {lines[head + 1]!r}" + ) + + rows = [lines[head]] + for line in lines[head + 2 :]: + if not line.strip(): + break + rows.append(line) + + for row in rows: + occupied = {index for index, char in enumerate(row) if char != " "} + inside = {index for start, end in spans for index in range(start, end)} + assert occupied <= inside, ( + f"row {row!r} spills outside its columns " + f"(offsets {sorted(occupied - inside)}) under heading {lines[head]!r}" + ) + + # Within a column, every cell shares an edge: all flush left or all flush + # right. This is what keeps a heading sitting over its own numbers. + for start, end in spans: + cells = [row[start:end] for row in rows if row[start:end].strip()] + flush_left = not any(cell.startswith(" ") for cell in cells) + flush_right = not any(cell.endswith(" ") for cell in cells) + assert flush_left or flush_right, ( + f"column at {start}:{end} mixes alignment: {cells!r}" + ) + + +@pytest.mark.parametrize( + ("df1_name", "df2_name", "column"), + [ + ("left", "right", "val"), + ("warehouse_prod_snapshot", "stg", "val"), + ("a", "b", "a_column_name_far_longer_than_the_old_twenty_char_field"), + ], + ids=["short", "long_dataset_name", "long_column_name"], +) +def test_report_tables_stay_aligned(df1_name, df2_name, column): + """Long dataset and column names must not knock the tables out of line. + + Column widths used to be hard coded, so a name wider than its field pushed + every column to its right and left the body out of step with the heading. + """ + df1 = pd.DataFrame({"id": [1, 2], column: [1.0, 2.0]}) + df2 = pd.DataFrame({"id": [1, 2], column: [1.0, 9.0]}) + report = PandasCompare( + df1, df2, "id", df1_name=df1_name, df2_name=df2_name + ).report() + + _assert_table_aligned(report, "DataFrame ") + _assert_table_aligned(report, "Column ") + + +def test_report_truncates_overlong_column_names(): + """A very long column name is truncated to a fixed display width.""" + column = "a_column_name_far_longer_than_the_old_twenty_char_field" + df1 = pd.DataFrame({"id": [1, 2], column: [1.0, 2.0]}) + df2 = pd.DataFrame({"id": [1, 2], column: [1.0, 9.0]}) + report = PandasCompare(df1, df2, "id").report() + + assert f"{column[:17]}..." in report + assert column not in report.split("Sample Rows")[0]