From 2395aedbd1bfa44aa127df26c1dc593e9492f4c8 Mon Sep 17 00:00:00 2001 From: Faisal Dosani Date: Tue, 16 Jun 2026 22:12:56 -0400 Subject: [PATCH 01/23] feat: add comprehensive DataFrame comparison snapshots for Pandas and Polars --- tests/snapshots/pandas_duplicates.txt | 39 ++++++++++++++ tests/snapshots/pandas_no_mismatches.txt | 39 ++++++++++++++ tests/snapshots/pandas_on_index.txt | 53 ++++++++++++++++++ tests/snapshots/pandas_sample_count.txt | 55 +++++++++++++++++++ tests/snapshots/pandas_sample_count_zero.txt | 46 ++++++++++++++++ tests/snapshots/pandas_unique_columns.txt | 39 ++++++++++++++ tests/snapshots/pandas_unique_rows.txt | 52 ++++++++++++++++++ tests/snapshots/pandas_with_mismatches.txt | 57 ++++++++++++++++++++ tests/snapshots/pandas_with_tolerances.txt | 39 ++++++++++++++ tests/snapshots/polars_no_mismatches.txt | 39 ++++++++++++++ tests/snapshots/polars_unique_columns.txt | 39 ++++++++++++++ tests/snapshots/polars_unique_rows.txt | 52 ++++++++++++++++++ tests/snapshots/polars_with_mismatches.txt | 57 ++++++++++++++++++++ 13 files changed, 606 insertions(+) create mode 100644 tests/snapshots/pandas_duplicates.txt create mode 100644 tests/snapshots/pandas_no_mismatches.txt create mode 100644 tests/snapshots/pandas_on_index.txt create mode 100644 tests/snapshots/pandas_sample_count.txt create mode 100644 tests/snapshots/pandas_sample_count_zero.txt create mode 100644 tests/snapshots/pandas_unique_columns.txt create mode 100644 tests/snapshots/pandas_unique_rows.txt create mode 100644 tests/snapshots/pandas_with_mismatches.txt create mode 100644 tests/snapshots/pandas_with_tolerances.txt create mode 100644 tests/snapshots/polars_no_mismatches.txt create mode 100644 tests/snapshots/polars_unique_columns.txt create mode 100644 tests/snapshots/polars_unique_rows.txt create mode 100644 tests/snapshots/polars_with_mismatches.txt diff --git a/tests/snapshots/pandas_duplicates.txt b/tests/snapshots/pandas_duplicates.txt new file mode 100644 index 00000000..a09394f0 --- /dev/null +++ b/tests/snapshots/pandas_duplicates.txt @@ -0,0 +1,39 @@ +DataComPy Comparison +------------------- + +DataFrame Summary +----------------- + +DataFrame Columns Rows +------------------------ +df1 2 3 +df2 2 3 + + +Column Summary +-------------- + +Number of columns in common: 2 +Number of columns in df1 but not in df2: 0 +Number of columns in df2 but not in df1: 0 + +Row Summary +----------- + +Matched on: id +Any duplicates on match values: Yes +Default Absolute Tolerance: 0 +Default Relative Tolerance: 0 +Number of rows in common: 3 +Number of rows in df1 but not in df2: 0 +Number of rows in df2 but not in df1: 0 + +Number of rows with some compared columns unequal: 0 +Number of rows with all compared columns equal: 3 + +Column Comparison +----------------- + +Number of columns compared with some values unequal: 0 +Number of columns compared with all values equal: 2 +Total number of values which compare unequal: 0 diff --git a/tests/snapshots/pandas_no_mismatches.txt b/tests/snapshots/pandas_no_mismatches.txt new file mode 100644 index 00000000..eb2db7e2 --- /dev/null +++ b/tests/snapshots/pandas_no_mismatches.txt @@ -0,0 +1,39 @@ +DataComPy Comparison +------------------- + +DataFrame Summary +----------------- + +DataFrame Columns Rows +------------------------ +left 3 3 +right 3 3 + + +Column Summary +-------------- + +Number of columns in common: 3 +Number of columns in left but not in right: 0 +Number of columns in right but not in left: 0 + +Row Summary +----------- + +Matched on: id +Any duplicates on match values: No +Default Absolute Tolerance: 0 +Default Relative Tolerance: 0 +Number of rows in common: 3 +Number of rows in left but not in right: 0 +Number of rows in right but not in left: 0 + +Number of rows with some compared columns unequal: 0 +Number of rows with all compared columns equal: 3 + +Column Comparison +----------------- + +Number of columns compared with some values unequal: 0 +Number of columns compared with all values equal: 3 +Total number of values which compare unequal: 0 diff --git a/tests/snapshots/pandas_on_index.txt b/tests/snapshots/pandas_on_index.txt new file mode 100644 index 00000000..2c1901d4 --- /dev/null +++ b/tests/snapshots/pandas_on_index.txt @@ -0,0 +1,53 @@ +DataComPy Comparison +------------------- + +DataFrame Summary +----------------- + +DataFrame Columns Rows +------------------------ +df1 1 3 +df2 1 3 + + +Column Summary +-------------- + +Number of columns in common: 1 +Number of columns in df1 but not in df2: 0 +Number of columns in df2 but not in df1: 0 + +Row Summary +----------- + +Matched on: index +Any duplicates on match values: No +Default Absolute Tolerance: 0 +Default Relative Tolerance: 0 +Number of rows in common: 3 +Number of rows in df1 but not in df2: 0 +Number of rows in df2 but not in df1: 0 + +Number of rows with some compared columns unequal: 1 +Number of rows with all compared columns equal: 2 + +Column Comparison +----------------- + +Number of columns compared with some values unequal: 1 +Number of columns compared with all values equal: 0 +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 + + +Sample Rows with Unequal Values +------------------------------- + + val (df1) val (df2) +0 20 99 diff --git a/tests/snapshots/pandas_sample_count.txt b/tests/snapshots/pandas_sample_count.txt new file mode 100644 index 00000000..991dd359 --- /dev/null +++ b/tests/snapshots/pandas_sample_count.txt @@ -0,0 +1,55 @@ +DataComPy Comparison +------------------- + +DataFrame Summary +----------------- + +DataFrame Columns Rows +------------------------ +df1 2 3 +df2 2 3 + + +Column Summary +-------------- + +Number of columns in common: 2 +Number of columns in df1 but not in df2: 0 +Number of columns in df2 but not in df1: 0 + +Row Summary +----------- + +Matched on: id +Any duplicates on match values: No +Default Absolute Tolerance: 0 +Default Relative Tolerance: 0 +Number of rows in common: 3 +Number of rows in df1 but not in df2: 0 +Number of rows in df2 but not in df1: 0 + +Number of rows with some compared columns unequal: 3 +Number of rows with all compared columns equal: 0 + +Column Comparison +----------------- + +Number of columns compared with some values unequal: 1 +Number of columns compared with all values equal: 1 +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 + + +Sample Rows with Unequal Values +------------------------------- + + id val (df1) val (df2) +0 1 10 11 +1 2 20 22 +2 3 30 33 diff --git a/tests/snapshots/pandas_sample_count_zero.txt b/tests/snapshots/pandas_sample_count_zero.txt new file mode 100644 index 00000000..d3cdfeb8 --- /dev/null +++ b/tests/snapshots/pandas_sample_count_zero.txt @@ -0,0 +1,46 @@ +DataComPy Comparison +------------------- + +DataFrame Summary +----------------- + +DataFrame Columns Rows +------------------------ +df1 2 3 +df2 2 3 + + +Column Summary +-------------- + +Number of columns in common: 2 +Number of columns in df1 but not in df2: 0 +Number of columns in df2 but not in df1: 0 + +Row Summary +----------- + +Matched on: id +Any duplicates on match values: No +Default Absolute Tolerance: 0 +Default Relative Tolerance: 0 +Number of rows in common: 3 +Number of rows in df1 but not in df2: 0 +Number of rows in df2 but not in df1: 0 + +Number of rows with some compared columns unequal: 3 +Number of rows with all compared columns equal: 0 + +Column Comparison +----------------- + +Number of columns compared with some values unequal: 1 +Number of columns compared with all values equal: 1 +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 diff --git a/tests/snapshots/pandas_unique_columns.txt b/tests/snapshots/pandas_unique_columns.txt new file mode 100644 index 00000000..4f5bf81e --- /dev/null +++ b/tests/snapshots/pandas_unique_columns.txt @@ -0,0 +1,39 @@ +DataComPy Comparison +------------------- + +DataFrame Summary +----------------- + +DataFrame Columns Rows +------------------------ +df1 3 2 +df2 3 2 + + +Column Summary +-------------- + +Number of columns in common: 2 +Number of columns in df1 but not in df2: 1 ['only_1'] +Number of columns in df2 but not in df1: 1 ['only_2'] + +Row Summary +----------- + +Matched on: id +Any duplicates on match values: No +Default Absolute Tolerance: 0 +Default Relative Tolerance: 0 +Number of rows in common: 2 +Number of rows in df1 but not in df2: 0 +Number of rows in df2 but not in df1: 0 + +Number of rows with some compared columns unequal: 0 +Number of rows with all compared columns equal: 2 + +Column Comparison +----------------- + +Number of columns compared with some values unequal: 0 +Number of columns compared with all values equal: 2 +Total number of values which compare unequal: 0 diff --git a/tests/snapshots/pandas_unique_rows.txt b/tests/snapshots/pandas_unique_rows.txt new file mode 100644 index 00000000..bc2cc502 --- /dev/null +++ b/tests/snapshots/pandas_unique_rows.txt @@ -0,0 +1,52 @@ +DataComPy Comparison +------------------- + +DataFrame Summary +----------------- + +DataFrame Columns Rows +------------------------ +df1 2 3 +df2 2 3 + + +Column Summary +-------------- + +Number of columns in common: 2 +Number of columns in df1 but not in df2: 0 +Number of columns in df2 but not in df1: 0 + +Row Summary +----------- + +Matched on: id +Any duplicates on match values: No +Default Absolute Tolerance: 0 +Default Relative Tolerance: 0 +Number of rows in common: 2 +Number of rows in df1 but not in df2: 1 +Number of rows in df2 but not in df1: 1 + +Number of rows with some compared columns unequal: 0 +Number of rows with all compared columns equal: 2 + +Column Comparison +----------------- + +Number of columns compared with some values unequal: 0 +Number of columns compared with all values equal: 2 +Total number of values which compare unequal: 0 + + +Sample Rows Only in df1 (First 10 Columns) +------------------------------------------ + + id val +0 3 30.0 + +Sample Rows Only in df2 (First 10 Columns) +------------------------------------------ + + id val +0 4 40.0 diff --git a/tests/snapshots/pandas_with_mismatches.txt b/tests/snapshots/pandas_with_mismatches.txt new file mode 100644 index 00000000..b717aba4 --- /dev/null +++ b/tests/snapshots/pandas_with_mismatches.txt @@ -0,0 +1,57 @@ +DataComPy Comparison +------------------- + +DataFrame Summary +----------------- + +DataFrame Columns Rows +------------------------ +df1 3 3 +df2 3 3 + + +Column Summary +-------------- + +Number of columns in common: 3 +Number of columns in df1 but not in df2: 0 +Number of columns in df2 but not in df1: 0 + +Row Summary +----------- + +Matched on: id +Any duplicates on match values: No +Default Absolute Tolerance: 0 +Default Relative Tolerance: 0 +Number of rows in common: 3 +Number of rows in df1 but not in df2: 0 +Number of rows in df2 but not in df1: 0 + +Number of rows with some compared columns unequal: 1 +Number of rows with all compared columns equal: 2 + +Column Comparison +----------------- + +Number of columns compared with some values unequal: 2 +Number of columns compared with all values equal: 1 +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 + + +Sample Rows with Unequal Values +------------------------------- + + id val (df1) val (df2) +0 2 20 99 + + id score (df1) score (df2) +0 2 2.0 2.5 diff --git a/tests/snapshots/pandas_with_tolerances.txt b/tests/snapshots/pandas_with_tolerances.txt new file mode 100644 index 00000000..936fc1d7 --- /dev/null +++ b/tests/snapshots/pandas_with_tolerances.txt @@ -0,0 +1,39 @@ +DataComPy Comparison +------------------- + +DataFrame Summary +----------------- + +DataFrame Columns Rows +------------------------ +df1 2 2 +df2 2 2 + + +Column Summary +-------------- + +Number of columns in common: 2 +Number of columns in df1 but not in df2: 0 +Number of columns in df2 but not in df1: 0 + +Row Summary +----------- + +Matched on: id +Any duplicates on match values: No +Default Absolute Tolerance: 0.001 +Default Relative Tolerance: 0 +Number of rows in common: 2 +Number of rows in df1 but not in df2: 0 +Number of rows in df2 but not in df1: 0 + +Number of rows with some compared columns unequal: 0 +Number of rows with all compared columns equal: 2 + +Column Comparison +----------------- + +Number of columns compared with some values unequal: 0 +Number of columns compared with all values equal: 2 +Total number of values which compare unequal: 0 diff --git a/tests/snapshots/polars_no_mismatches.txt b/tests/snapshots/polars_no_mismatches.txt new file mode 100644 index 00000000..2c25f5ea --- /dev/null +++ b/tests/snapshots/polars_no_mismatches.txt @@ -0,0 +1,39 @@ +DataComPy Comparison +------------------- + +DataFrame Summary +----------------- + +DataFrame Columns Rows +------------------------ +left 2 3 +right 2 3 + + +Column Summary +-------------- + +Number of columns in common: 2 +Number of columns in left but not in right: 0 +Number of columns in right but not in left: 0 + +Row Summary +----------- + +Matched on: id +Any duplicates on match values: No +Default Absolute Tolerance: 0 +Default Relative Tolerance: 0 +Number of rows in common: 3 +Number of rows in left but not in right: 0 +Number of rows in right but not in left: 0 + +Number of rows with some compared columns unequal: 0 +Number of rows with all compared columns equal: 3 + +Column Comparison +----------------- + +Number of columns compared with some values unequal: 0 +Number of columns compared with all values equal: 2 +Total number of values which compare unequal: 0 diff --git a/tests/snapshots/polars_unique_columns.txt b/tests/snapshots/polars_unique_columns.txt new file mode 100644 index 00000000..4f5bf81e --- /dev/null +++ b/tests/snapshots/polars_unique_columns.txt @@ -0,0 +1,39 @@ +DataComPy Comparison +------------------- + +DataFrame Summary +----------------- + +DataFrame Columns Rows +------------------------ +df1 3 2 +df2 3 2 + + +Column Summary +-------------- + +Number of columns in common: 2 +Number of columns in df1 but not in df2: 1 ['only_1'] +Number of columns in df2 but not in df1: 1 ['only_2'] + +Row Summary +----------- + +Matched on: id +Any duplicates on match values: No +Default Absolute Tolerance: 0 +Default Relative Tolerance: 0 +Number of rows in common: 2 +Number of rows in df1 but not in df2: 0 +Number of rows in df2 but not in df1: 0 + +Number of rows with some compared columns unequal: 0 +Number of rows with all compared columns equal: 2 + +Column Comparison +----------------- + +Number of columns compared with some values unequal: 0 +Number of columns compared with all values equal: 2 +Total number of values which compare unequal: 0 diff --git a/tests/snapshots/polars_unique_rows.txt b/tests/snapshots/polars_unique_rows.txt new file mode 100644 index 00000000..1842b673 --- /dev/null +++ b/tests/snapshots/polars_unique_rows.txt @@ -0,0 +1,52 @@ +DataComPy Comparison +------------------- + +DataFrame Summary +----------------- + +DataFrame Columns Rows +------------------------ +df1 2 3 +df2 2 3 + + +Column Summary +-------------- + +Number of columns in common: 2 +Number of columns in df1 but not in df2: 0 +Number of columns in df2 but not in df1: 0 + +Row Summary +----------- + +Matched on: id +Any duplicates on match values: No +Default Absolute Tolerance: 0 +Default Relative Tolerance: 0 +Number of rows in common: 2 +Number of rows in df1 but not in df2: 1 +Number of rows in df2 but not in df1: 1 + +Number of rows with some compared columns unequal: 0 +Number of rows with all compared columns equal: 2 + +Column Comparison +----------------- + +Number of columns compared with some values unequal: 0 +Number of columns compared with all values equal: 2 +Total number of values which compare unequal: 0 + + +Sample Rows Only in df1 (First 10 Columns) +------------------------------------------ + + id val +0 3 30 + +Sample Rows Only in df2 (First 10 Columns) +------------------------------------------ + + id val +0 4 40 diff --git a/tests/snapshots/polars_with_mismatches.txt b/tests/snapshots/polars_with_mismatches.txt new file mode 100644 index 00000000..55011c78 --- /dev/null +++ b/tests/snapshots/polars_with_mismatches.txt @@ -0,0 +1,57 @@ +DataComPy Comparison +------------------- + +DataFrame Summary +----------------- + +DataFrame Columns Rows +------------------------ +df1 3 3 +df2 3 3 + + +Column Summary +-------------- + +Number of columns in common: 3 +Number of columns in df1 but not in df2: 0 +Number of columns in df2 but not in df1: 0 + +Row Summary +----------- + +Matched on: id +Any duplicates on match values: No +Default Absolute Tolerance: 0 +Default Relative Tolerance: 0 +Number of rows in common: 3 +Number of rows in df1 but not in df2: 0 +Number of rows in df2 but not in df1: 0 + +Number of rows with some compared columns unequal: 1 +Number of rows with all compared columns equal: 2 + +Column Comparison +----------------- + +Number of columns compared with some values unequal: 2 +Number of columns compared with all values equal: 1 +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 + + +Sample Rows with Unequal Values +------------------------------- + + id val (df1) val (df2) +0 2 20 99 + + id score (df1) score (df2) +0 2 2.0 2.5 From 0bafb5b242366ce3c323c39f445ccfed77790f0b Mon Sep 17 00:00:00 2001 From: Faisal Dosani Date: Tue, 16 Jun 2026 22:32:56 -0400 Subject: [PATCH 02/23] fix: update Pandas usage examples to use PandasCompare class --- docs/source/pandas_usage.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/source/pandas_usage.rst b/docs/source/pandas_usage.rst index 85481b7a..6d826359 100644 --- a/docs/source/pandas_usage.rst +++ b/docs/source/pandas_usage.rst @@ -39,7 +39,7 @@ Set up like: from io import StringIO import pandas as pd - from datacompy.core import Compare + from datacompy.pandas import PandasCompare data1 = """acct_id,dollar_amt,name,float_fld,date_fld 10000001234,123.45,George Maharis,14530.1555,2017-01-01 @@ -69,7 +69,7 @@ join column(s) or by index. .. code-block:: python - compare = Compare( + compare = PandasCompare( df1, df2, join_columns='acct_id', #You can also specify a list of columns @@ -80,16 +80,16 @@ join column(s) or by index. # OR - compare = Compare(df1, df2, join_columns=['acct_id', 'name']) + compare = PandasCompare(df1, df2, join_columns=['acct_id', 'name']) # OR - compare = Compare(df1, df2, on_index=True) + compare = PandasCompare(df1, df2, on_index=True) Reports ------- -A report is generated by calling ``Compare.report()``, which returns a string. +A report is generated by calling ``PandasCompare.report()``, which returns a string. Here is a sample report generated by ``datacompy`` for the two tables above, joined on ``acct_id`` (Note: if you don't specify ``df1_name`` and/or ``df2_name``, then any instance of "original" or "new" in the report is replaced with "df1" From ca8c04b401a663ffec9a9913edec341b25fdc2d5 Mon Sep 17 00:00:00 2001 From: Faisal Dosani Date: Tue, 16 Jun 2026 22:33:19 -0400 Subject: [PATCH 03/23] feat: add Report API documentation to enhance user guidance --- docs/source/index.rst | 1 + docs/source/report_api.rst | 79 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 docs/source/report_api.rst diff --git a/docs/source/index.rst b/docs/source/index.rst index cfabc827..46ee380a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -15,6 +15,7 @@ Contents Snowflake Usage Polars Usage Template Guide + Report API Benchmarks Developer Instructions diff --git a/docs/source/report_api.rst b/docs/source/report_api.rst new file mode 100644 index 00000000..f097060f --- /dev/null +++ b/docs/source/report_api.rst @@ -0,0 +1,79 @@ +Report API +========== + +DataComPy's ``datacompy.report`` module provides a typed data model and +rendering class that every backend uses to generate comparison reports. +All backends share the same output structure, making it easy to build +custom dashboards, export to JSON, or plug in alternative templates. + +Programmatic Access +------------------- + +Call ``compare.build_report_data()`` on any compare object to get a +:class:`~datacompy.report.ReportData` instance **without** triggering +any rendering: + +.. code-block:: python + + import pandas as pd + from datacompy import PandasCompare + + df1 = pd.DataFrame({"id": [1, 2, 3], "val": [10, 20, 30]}) + df2 = pd.DataFrame({"id": [1, 2, 3], "val": [10, 99, 30]}) + + compare = PandasCompare(df1, df2, join_columns="id") + data = compare.build_report_data() + + # Inspect structured fields directly + print(data.row_summary.unequal_rows) # 1 + print(data.mismatch_stats.stats[0].column) # 'val' + print(data.column_summary.common_columns) # 2 + +The same method is available on all backends (``PolarsCompare``, +``SparkSQLCompare``, ``SnowflakeCompare``). + +Rendering and Export +-------------------- + +Wrap a :class:`~datacompy.report.ReportData` in a +:class:`~datacompy.report.Report` for rendering: + +.. code-block:: python + + from datacompy import Report + + data = compare.build_report_data() + rep = Report(data) + + # Plain-text report (same as compare.report()) + print(rep.render()) + + # Save HTML file + rep.save("comparison.html") + + # JSON-serializable dict (for dashboards / APIs) + import json + payload = json.dumps(rep.to_dict()) + +Custom Templates +~~~~~~~~~~~~~~~~ + +Pass a ``template_path`` to use your own Jinja2 template: + +.. code-block:: python + + rep = Report(data, template_path="my_report.j2") + print(rep.render()) + +The template receives the same context dict as the default +``report_template.j2``; see :doc:`template_guide` for the full variable +reference. + +Data Classes +------------ + +.. automodule:: datacompy.report + :members: ReportData, ColumnSummary, RowSummary, ColumnComparison, + MismatchStat, MismatchStats, UniqueRowsData, Report + :show-inheritance: + :undoc-members: From 9ba9816d8f94b0681baa56191d17a3a6113f9776 Mon Sep 17 00:00:00 2001 From: Faisal Dosani Date: Tue, 16 Jun 2026 22:34:42 -0400 Subject: [PATCH 04/23] fix: update mock patches in save_html tests to use report and base modules --- tests/test_pandas.py | 4 ++-- tests/test_polars.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_pandas.py b/tests/test_pandas.py index 69fc4e2c..ba1a4877 100644 --- a/tests/test_pandas.py +++ b/tests/test_pandas.py @@ -1378,8 +1378,8 @@ def test_integer_column_names(): assert compare.matches() -@mock.patch("datacompy.pandas.render") -@mock.patch("datacompy.pandas.save_html_report") +@mock.patch("datacompy.report.render") +@mock.patch("datacompy.base.save_html_report") def test_save_html(mock_save_html, mock_render): df1 = pd.DataFrame([{"a": 1, "b": 2}, {"a": 2, "b": 3}]) df2 = pd.DataFrame([{"a": 1, "b": 2}, {"a": 2, "b": 4}]) diff --git a/tests/test_polars.py b/tests/test_polars.py index 13bb67e5..54591f4a 100644 --- a/tests/test_polars.py +++ b/tests/test_polars.py @@ -1322,8 +1322,8 @@ def test_lower(): ) -@mock.patch("datacompy.polars.render") -@mock.patch("datacompy.polars.save_html_report") +@mock.patch("datacompy.report.render") +@mock.patch("datacompy.base.save_html_report") def test_save_html(mock_save_html, mock_render): df1 = pl.DataFrame([{"a": 1, "b": 2}, {"a": 1, "b": 2}]) df2 = pl.DataFrame([{"a": 1, "b": 2}, {"a": 1, "b": 2}]) From 7d0698647d9c6bd686e914c05b27b95b1521f732 Mon Sep 17 00:00:00 2001 From: Faisal Dosani Date: Tue, 16 Jun 2026 22:37:34 -0400 Subject: [PATCH 05/23] feat: add unit and snapshot regression tests for Report class across Pandas and Polars backends --- tests/test_report.py | 358 +++++++++++++++++++++++++++++++++ tests/test_report_snapshots.py | 132 ++++++++++++ 2 files changed, 490 insertions(+) create mode 100644 tests/test_report.py create mode 100644 tests/test_report_snapshots.py diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 00000000..0c924d23 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,358 @@ +"""Unit tests for datacompy.report dataclasses and Report class.""" + +import dataclasses +import json +import os + +import pandas as pd +import polars as pl +import pytest +from datacompy import ( + ColumnComparison, + ColumnSummary, + MismatchStat, + MismatchStats, + PandasCompare, + PolarsCompare, + Report, + ReportData, + RowSummary, + UniqueRowsData, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _minimal_report_data( + df1_name: str = "df1", + df2_name: str = "df2", + unequal_rows: int = 0, + mismatch: bool = False, +) -> ReportData: + stat = ( + ( + MismatchStat( + column="val", + dtype1="int64", + dtype2="int64", + unequal_cnt=1, + max_diff=1.0, + null_diff=0, + rel_tol=0.0, + abs_tol=0.0, + ), + ) + if mismatch + else () + ) + + return ReportData( + df1_name=df1_name, + df2_name=df2_name, + df1_shape=(3, 2), + df2_shape=(3, 2), + column_count=10, + column_summary=ColumnSummary( + common_columns=2, + df1_unique=0, + df1_unique_columns=(), + df2_unique=0, + df2_unique_columns=(), + df1_name=df1_name, + df2_name=df2_name, + ), + row_summary=RowSummary( + match_columns=("id",), + on_index=False, + has_duplicates=False, + abs_tol=0, + rel_tol=0, + common_rows=3, + df1_unique=0, + df2_unique=0, + unequal_rows=unequal_rows, + equal_rows=3 - unequal_rows, + df1_name=df1_name, + df2_name=df2_name, + ), + column_comparison=ColumnComparison( + unequal_columns=1 if mismatch else 0, + equal_columns=1 if not mismatch else 0, + unequal_values=1 if mismatch else 0, + ), + mismatch_stats=MismatchStats( + has_mismatches=mismatch, + has_samples=mismatch, + stats=stat, + samples=("col df1 df2\n 0 1 2",) if mismatch else (), + df1_name=df1_name, + df2_name=df2_name, + ), + df1_unique_rows=UniqueRowsData(has_rows=False), + df2_unique_rows=UniqueRowsData(has_rows=False), + ) + + +# --------------------------------------------------------------------------- +# Dataclass tests +# --------------------------------------------------------------------------- + + +class TestDataclasses: + def test_report_data_is_frozen(self): + data = _minimal_report_data() + with pytest.raises((dataclasses.FrozenInstanceError, AttributeError)): + data.df1_name = "changed" # type: ignore[misc] + + def test_mismatch_stat_frozen(self): + stat = MismatchStat("col", "int64", "int64", 1, 1.0, 0, 0.0, 0.0) + with pytest.raises((dataclasses.FrozenInstanceError, AttributeError)): + stat.column = "other" # type: ignore[misc] + + def test_mismatch_stats_defaults(self): + ms = MismatchStats(has_mismatches=False, has_samples=False) + assert ms.stats == () + assert ms.samples == () + assert ms.df1_name == "" + + def test_unique_rows_data_defaults(self): + u = UniqueRowsData(has_rows=False) + assert u.rows == "" + + +# --------------------------------------------------------------------------- +# to_template_context tests +# --------------------------------------------------------------------------- + + +class TestToTemplateContext: + def test_basic_keys_present(self): + ctx = _minimal_report_data().to_template_context() + for key in [ + "df1_name", + "df2_name", + "df1_shape", + "df2_shape", + "column_count", + "column_summary", + "row_summary", + "column_comparison", + "mismatch_stats", + "df1_unique_rows", + "df2_unique_rows", + ]: + assert key in ctx + + def test_row_summary_match_columns_joined(self): + data = _minimal_report_data() + ctx = data.to_template_context() + assert ctx["row_summary"]["match_columns"] == "id" + + def test_row_summary_on_index(self): + data = dataclasses.replace( + _minimal_report_data(), + row_summary=dataclasses.replace( + _minimal_report_data().row_summary, on_index=True + ), + ) + ctx = data.to_template_context() + assert ctx["row_summary"]["match_columns"] == "index" + + def test_row_summary_has_duplicates_string(self): + data = _minimal_report_data() + ctx = data.to_template_context() + assert ctx["row_summary"]["has_duplicates"] in ("Yes", "No") + + def test_df1_unique_formatted_with_cols(self): + data = dataclasses.replace( + _minimal_report_data(), + column_summary=dataclasses.replace( + _minimal_report_data().column_summary, + df1_unique=2, + df1_unique_columns=("col_a", "col_b"), + ), + ) + ctx = data.to_template_context() + assert ctx["column_summary"]["df1_unique"] == "2 ['col_a', 'col_b']" + + def test_df1_unique_plain_int_when_no_unique_cols(self): + ctx = _minimal_report_data().to_template_context() + assert ctx["column_summary"]["df1_unique"] == 0 + + def test_mismatch_stats_stats_is_list_of_dicts(self): + data = _minimal_report_data(mismatch=True) + ctx = data.to_template_context() + assert isinstance(ctx["mismatch_stats"]["stats"], list) + assert isinstance(ctx["mismatch_stats"]["stats"][0], dict) + assert "column" in ctx["mismatch_stats"]["stats"][0] + + +# --------------------------------------------------------------------------- +# Report class tests +# --------------------------------------------------------------------------- + + +class TestReport: + def test_render_returns_string(self): + rep = Report(_minimal_report_data()) + text = rep.render() + assert isinstance(text, str) + assert "DataComPy" in text + + def test_str_equals_render(self): + rep = Report(_minimal_report_data()) + assert str(rep) == rep.render() + + def test_repr(self): + rep = Report(_minimal_report_data("left", "right")) + r = repr(rep) + assert "left" in r + assert "right" in r + + def test_to_html_contains_pre(self): + rep = Report(_minimal_report_data()) + html = rep.to_html() + assert "
" in html
+        assert "" in html.lower()
+
+    def test_save_writes_html_file(self, tmp_path):
+        rep = Report(_minimal_report_data())
+        dest = tmp_path / "report.html"
+        rep.save(dest)
+        assert dest.exists()
+        content = dest.read_text()
+        assert "
" in content
+
+    def test_to_dict_json_serializable(self):
+        data = _minimal_report_data(mismatch=True)
+        rep = Report(data)
+        d = rep.to_dict()
+        json_str = json.dumps(d)
+        assert "df1_name" in json_str
+
+    def test_to_dict_roundtrip_field(self):
+        data = _minimal_report_data(mismatch=True)
+        rep = Report(data)
+        d = rep.to_dict()
+        assert d["row_summary"]["common_rows"] == 3
+        assert d["mismatch_stats"]["stats"][0]["column"] == "val"
+
+    def test_data_property(self):
+        data = _minimal_report_data()
+        rep = Report(data)
+        assert rep.data is data
+
+    def test_custom_template(self, tmp_path):
+        tmpl = tmp_path / "custom.j2"
+        tmpl.write_text("{{ df1_name }} vs {{ df2_name }}")
+        rep = Report(_minimal_report_data("left", "right"), template_path=str(tmpl))
+        assert rep.render() == "left vs right"
+
+    def test_report_with_mismatches(self):
+        data = _minimal_report_data(mismatch=True)
+        rep = Report(data)
+        text = rep.render()
+        assert "Columns with Unequal" in text
+        assert "val" in text
+
+    def test_report_no_mismatches(self):
+        data = _minimal_report_data(mismatch=False)
+        rep = Report(data)
+        text = rep.render()
+        assert "Columns with Unequal" not in text
+
+
+# ---------------------------------------------------------------------------
+# Integration: build_report_data roundtrip via pandas and polars
+# ---------------------------------------------------------------------------
+
+
+class TestBuildReportData:
+    def test_pandas_returns_report_data(self):
+        df1 = pd.DataFrame({"id": [1, 2, 3], "val": [1, 2, 3]})
+        df2 = pd.DataFrame({"id": [1, 2, 3], "val": [1, 9, 3]})
+        c = PandasCompare(df1, df2, "id")
+        data = c.build_report_data()
+        assert isinstance(data, ReportData)
+        assert data.df1_shape == (3, 2)
+        assert data.row_summary.common_rows == 3
+        assert data.row_summary.unequal_rows == 1
+        assert data.mismatch_stats.has_mismatches is True
+        assert data.mismatch_stats.stats[0].column == "val"
+
+    def test_pandas_mismatch_stats_sorted(self):
+        df1 = pd.DataFrame({"id": [1, 2], "b": [1, 2], "a": [10, 20]})
+        df2 = pd.DataFrame({"id": [1, 2], "b": [9, 9], "a": [99, 99]})
+        c = PandasCompare(df1, df2, "id")
+        data = c.build_report_data()
+        cols = [s.column for s in data.mismatch_stats.stats]
+        assert cols == sorted(cols)
+
+    def test_pandas_no_mismatches(self):
+        df1 = pd.DataFrame({"id": [1, 2], "val": [1, 2]})
+        df2 = pd.DataFrame({"id": [1, 2], "val": [1, 2]})
+        c = PandasCompare(df1, df2, "id")
+        data = c.build_report_data()
+        assert data.mismatch_stats.has_mismatches is False
+        assert data.mismatch_stats.stats == ()
+
+    def test_pandas_unique_columns(self):
+        df1 = pd.DataFrame({"id": [1], "only_in_1": [9]})
+        df2 = pd.DataFrame({"id": [1], "only_in_2": [9]})
+        c = PandasCompare(df1, df2, "id")
+        data = c.build_report_data()
+        assert data.column_summary.df1_unique_columns == ("only_in_1",)
+        assert data.column_summary.df2_unique_columns == ("only_in_2",)
+
+    def test_pandas_unique_rows(self):
+        df1 = pd.DataFrame({"id": [1, 2, 3], "val": [1, 2, 3]})
+        df2 = pd.DataFrame({"id": [1, 2], "val": [1, 2]})
+        c = PandasCompare(df1, df2, "id")
+        data = c.build_report_data()
+        assert data.df1_unique_rows.has_rows is True
+        assert "3" in data.df1_unique_rows.rows
+        assert data.df2_unique_rows.has_rows is False
+
+    def test_pandas_on_index(self):
+        df1 = pd.DataFrame({"val": [1, 2]}, index=[0, 1])
+        df2 = pd.DataFrame({"val": [1, 9]}, index=[0, 1])
+        c = PandasCompare(df1, df2, on_index=True)
+        data = c.build_report_data()
+        assert data.row_summary.on_index is True
+        ctx = data.to_template_context()
+        assert ctx["row_summary"]["match_columns"] == "index"
+
+    def test_pandas_html_file(self, tmp_path):
+        df1 = pd.DataFrame({"id": [1], "val": [1]})
+        df2 = pd.DataFrame({"id": [1], "val": [1]})
+        c = PandasCompare(df1, df2, "id")
+        html_path = str(tmp_path / "report.html")
+        text = c.report(html_file=html_path)
+        assert isinstance(text, str)
+        assert os.path.exists(html_path)
+
+    def test_polars_returns_report_data(self):
+        df1 = pl.DataFrame({"id": [1, 2], "val": [10, 20]})
+        df2 = pl.DataFrame({"id": [1, 2], "val": [10, 99]})
+        c = PolarsCompare(df1, df2, "id")
+        data = c.build_report_data()
+        assert isinstance(data, ReportData)
+        assert data.mismatch_stats.has_mismatches is True
+
+    def test_polars_mismatch_stats_sorted(self):
+        df1 = pl.DataFrame({"id": [1], "z_col": [1], "a_col": [10]})
+        df2 = pl.DataFrame({"id": [1], "z_col": [9], "a_col": [99]})
+        c = PolarsCompare(df1, df2, "id")
+        data = c.build_report_data()
+        cols = [s.column for s in data.mismatch_stats.stats]
+        assert cols == sorted(cols)
+
+    def test_report_method_returns_string(self):
+        df1 = pd.DataFrame({"id": [1, 2], "val": [1, 2]})
+        df2 = pd.DataFrame({"id": [1, 2], "val": [1, 9]})
+        c = PandasCompare(df1, df2, "id")
+        rpt = c.report()
+        assert isinstance(rpt, str)
+        assert "DataComPy" in rpt
diff --git a/tests/test_report_snapshots.py b/tests/test_report_snapshots.py
new file mode 100644
index 00000000..159729d8
--- /dev/null
+++ b/tests/test_report_snapshots.py
@@ -0,0 +1,132 @@
+"""Snapshot regression tests for report() output across backends.
+
+Each test case generates a report and compares it against a committed snapshot
+in tests/snapshots/.  To regenerate all snapshots:
+
+    DATACOMPY_REGEN_SNAPSHOTS=1 pytest tests/test_report_snapshots.py
+
+Snapshots are named ``{backend}_{case}.txt``.  Any intentional change to
+report output (e.g. adding a new section) must be accompanied by a snapshot
+update committed alongside the code change.
+"""
+
+import os
+from pathlib import Path
+
+import pandas as pd
+import polars as pl
+from datacompy import PandasCompare, PolarsCompare
+
+SNAPSHOT_DIR = Path(__file__).parent / "snapshots"
+REGEN = os.environ.get("DATACOMPY_REGEN_SNAPSHOTS") == "1"
+
+
+def _assert_or_regen(name: str, text: str) -> None:
+    path = SNAPSHOT_DIR / f"{name}.txt"
+    if REGEN or not path.exists():
+        SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
+        path.write_text(text, encoding="utf-8")
+        return
+    expected = path.read_text(encoding="utf-8")
+    assert text == expected, (
+        f"Report output for '{name}' changed.\n"
+        f"Run with DATACOMPY_REGEN_SNAPSHOTS=1 if the change is intentional.\n"
+        f"First diff at char {next((i for i, (a, b) in enumerate(zip(text, expected, strict=False)) if a != b), len(min(text, expected, key=len)))}"
+    )
+
+
+# ---------------------------------------------------------------------------
+# Pandas
+# ---------------------------------------------------------------------------
+
+
+class TestPandasSnapshots:
+    def test_no_mismatches(self):
+        df1 = pd.DataFrame(
+            {"id": [1, 2, 3], "val": [10, 20, 30], "name": ["a", "b", "c"]}
+        )
+        df2 = df1.copy()
+        c = PandasCompare(df1, df2, "id", df1_name="left", df2_name="right")
+        _assert_or_regen("pandas_no_mismatches", c.report())
+
+    def test_with_mismatches(self):
+        df1 = pd.DataFrame(
+            {"id": [1, 2, 3], "val": [10, 20, 30], "score": [1.0, 2.0, 3.0]}
+        )
+        df2 = pd.DataFrame(
+            {"id": [1, 2, 3], "val": [10, 99, 30], "score": [1.0, 2.5, 3.0]}
+        )
+        c = PandasCompare(df1, df2, "id")
+        _assert_or_regen("pandas_with_mismatches", c.report())
+
+    def test_unique_rows(self):
+        df1 = pd.DataFrame({"id": [1, 2, 3], "val": [10, 20, 30]})
+        df2 = pd.DataFrame({"id": [1, 2, 4], "val": [10, 20, 40]})
+        c = PandasCompare(df1, df2, "id")
+        _assert_or_regen("pandas_unique_rows", c.report())
+
+    def test_unique_columns(self):
+        df1 = pd.DataFrame({"id": [1, 2], "shared": [1, 2], "only_1": [9, 9]})
+        df2 = pd.DataFrame({"id": [1, 2], "shared": [1, 2], "only_2": [8, 8]})
+        c = PandasCompare(df1, df2, "id")
+        _assert_or_regen("pandas_unique_columns", c.report())
+
+    def test_on_index(self):
+        df1 = pd.DataFrame({"val": [10, 20, 30]})
+        df2 = pd.DataFrame({"val": [10, 99, 30]})
+        c = PandasCompare(df1, df2, on_index=True)
+        _assert_or_regen("pandas_on_index", c.report())
+
+    def test_with_tolerances(self):
+        df1 = pd.DataFrame({"id": [1, 2], "val": [1.0, 2.0]})
+        df2 = pd.DataFrame({"id": [1, 2], "val": [1.0001, 2.0001]})
+        c = PandasCompare(df1, df2, "id", abs_tol=0.001)
+        _assert_or_regen("pandas_with_tolerances", c.report())
+
+    def test_duplicates(self):
+        df1 = pd.DataFrame({"id": [1, 1, 2], "val": [10, 20, 30]})
+        df2 = pd.DataFrame({"id": [1, 1, 2], "val": [10, 20, 30]})
+        c = PandasCompare(df1, df2, "id")
+        _assert_or_regen("pandas_duplicates", c.report())
+
+    def test_sample_count_zero(self):
+        # sample_count=0 disables sample rows — output is fully deterministic
+        df1 = pd.DataFrame({"id": [1, 2, 3], "val": [10, 20, 30]})
+        df2 = pd.DataFrame({"id": [1, 2, 3], "val": [11, 22, 33]})
+        c = PandasCompare(df1, df2, "id")
+        _assert_or_regen("pandas_sample_count_zero", c.report(sample_count=0))
+
+
+# ---------------------------------------------------------------------------
+# Polars
+# ---------------------------------------------------------------------------
+
+
+class TestPolarsSnapshots:
+    def test_no_mismatches(self):
+        df1 = pl.DataFrame({"id": [1, 2, 3], "val": [10, 20, 30]})
+        df2 = df1.clone()
+        c = PolarsCompare(df1, df2, "id", df1_name="left", df2_name="right")
+        _assert_or_regen("polars_no_mismatches", c.report())
+
+    def test_with_mismatches(self):
+        df1 = pl.DataFrame(
+            {"id": [1, 2, 3], "val": [10, 20, 30], "score": [1.0, 2.0, 3.0]}
+        )
+        df2 = pl.DataFrame(
+            {"id": [1, 2, 3], "val": [10, 99, 30], "score": [1.0, 2.5, 3.0]}
+        )
+        c = PolarsCompare(df1, df2, "id")
+        _assert_or_regen("polars_with_mismatches", c.report())
+
+    def test_unique_rows(self):
+        df1 = pl.DataFrame({"id": [1, 2, 3], "val": [10, 20, 30]})
+        df2 = pl.DataFrame({"id": [1, 2, 4], "val": [10, 20, 40]})
+        c = PolarsCompare(df1, df2, "id")
+        _assert_or_regen("polars_unique_rows", c.report())
+
+    def test_unique_columns(self):
+        df1 = pl.DataFrame({"id": [1, 2], "shared": [1, 2], "only_1": [9, 9]})
+        df2 = pl.DataFrame({"id": [1, 2], "shared": [1, 2], "only_2": [8, 8]})
+        c = PolarsCompare(df1, df2, "id")
+        _assert_or_regen("polars_unique_columns", c.report())

From a6c1700cc9073b5981308ca9531e37e3490b8e94 Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Tue, 16 Jun 2026 22:38:18 -0400
Subject: [PATCH 06/23] refactor: remove unused summary methods and add utility
 functions for DataFrame shape and column names

---
 datacompy/pandas.py    | 279 +----------------------------------------
 datacompy/polars.py    | 243 -----------------------------------
 datacompy/snowflake.py | 263 ++------------------------------------
 datacompy/spark.py     | 273 ++--------------------------------------
 4 files changed, 30 insertions(+), 1028 deletions(-)

diff --git a/datacompy/pandas.py b/datacompy/pandas.py
index 496e0d49..0e64d3e9 100644
--- a/datacompy/pandas.py
+++ b/datacompy/pandas.py
@@ -29,10 +29,7 @@
 
 from datacompy.base import (
     BaseCompare,
-    df_to_str,
     get_column_tolerance,
-    render,
-    save_html_report,
     temp_column_name,
     validate_tolerance_parameter,
 )
@@ -710,279 +707,11 @@ def all_mismatch(self, ignore_matching_cols: bool = False) -> pd.DataFrame:
         mm_bool = self.intersect_rows[match_list].all(axis="columns")
         return self.intersect_rows[~mm_bool][self.join_columns + return_list]
 
-    def _get_column_summary(self) -> dict:
-        """Generate column summary data for the report.
+    def _select_first_n_columns(self, df: Any, n: int) -> Any:
+        return df.iloc[:, :n]
 
-        Returns
-        -------
-        dict
-            Dictionary containing column summary information.
-        """
-        return {
-            "column_summary": {
-                "common_columns": len(self.intersect_columns()),
-                "df1_unique": len(self.df1_unq_columns()),
-                "df2_unique": len(self.df2_unq_columns()),
-                "df1_name": self.df1_name,
-                "df2_name": self.df2_name,
-            }
-        }
-
-    def _get_row_summary(self) -> dict:
-        """Generate row summary data for the report.
-
-        Returns
-        -------
-        dict
-            Dictionary containing row summary information.
-        """
-        return {
-            "row_summary": {
-                "match_columns": "index"
-                if self.on_index
-                else ", ".join(self.join_columns),
-                "abs_tol": self.abs_tol,
-                "rel_tol": self.rel_tol,
-                "common_rows": self.intersect_rows.shape[0],
-                "df1_unique": self.df1_unq_rows.shape[0],
-                "df2_unique": self.df2_unq_rows.shape[0],
-                "unequal_rows": self.intersect_rows.shape[0]
-                - self.count_matching_rows(),
-                "equal_rows": self.count_matching_rows(),
-                "df1_name": self.df1_name,
-                "df2_name": self.df2_name,
-                "has_duplicates": "Yes" if self._any_dupes else "No",
-            }
-        }
-
-    def _get_column_comparison(self) -> dict:
-        """Generate column comparison statistics for the report.
-
-        Returns
-        -------
-        dict
-            Dictionary containing column comparison information.
-        """
-        return {
-            "column_comparison": {
-                "unequal_columns": len(
-                    [col for col in self.column_stats if col["unequal_cnt"] > 0]
-                ),
-                "equal_columns": len(
-                    [col for col in self.column_stats if col["unequal_cnt"] == 0]
-                ),
-                "unequal_values": sum(col["unequal_cnt"] for col in self.column_stats),
-            }
-        }
-
-    def _get_mismatch_stats(self, sample_count: int) -> dict:
-        """Generate mismatch statistics for the report.
-
-        Parameters
-        ----------
-        sample_count : int
-            Number of samples to include in the report.
-
-        Returns
-        -------
-        dict
-            Dictionary containing mismatch statistics.
-        """
-        mismatch_stats = []
-        match_sample = []
-        any_mismatch = False
-
-        for column in self.column_stats:
-            if not column["all_match"]:
-                any_mismatch = True
-                mismatch_stats.append(
-                    {
-                        "column": column["column"],
-                        "dtype1": column["dtype1"],
-                        "dtype2": column["dtype2"],
-                        "unequal_cnt": column["unequal_cnt"],
-                        "max_diff": column["max_diff"],
-                        "null_diff": column["null_diff"],
-                        "rel_tol": column["rel_tol"],
-                        "abs_tol": column["abs_tol"],
-                    }
-                )
-                if column["unequal_cnt"] > 0:
-                    match_sample.append(
-                        self.sample_mismatch(
-                            column["column"], sample_count, for_display=True
-                        )
-                    )
-
-        if any_mismatch:
-            return {
-                "mismatch_stats": {
-                    "has_mismatches": True,
-                    "stats": sorted(mismatch_stats, key=lambda x: x["column"]),
-                    "df1_name": self.df1_name,
-                    "df2_name": self.df2_name,
-                    "samples": [df_to_str(sample) for sample in match_sample],
-                    "has_samples": len(match_sample) > 0 and sample_count > 0,
-                }
-            }
-        return {
-            "mismatch_stats": {
-                "has_mismatches": False,
-                "has_samples": False,
-            }
-        }
-
-    def _get_unique_rows_data(self, sample_count: int, column_count: int) -> dict:
-        """Generate data for unique rows in both dataframes.
-
-        Parameters
-        ----------
-        sample_count : int
-            Number of samples to include.
-        column_count : int
-            Number of columns to include.
-
-        Returns
-        -------
-        dict
-            Dictionary containing unique rows data for both dataframes.
-        """
-        min_sample_count_df1 = min(sample_count, self.df1_unq_rows.shape[0])
-        min_sample_count_df2 = min(sample_count, self.df2_unq_rows.shape[0])
-        min_column_count_df1 = min(column_count, self.df1_unq_rows.shape[1])
-        min_column_count_df2 = min(column_count, self.df2_unq_rows.shape[1])
-
-        return {
-            "df1_unique_rows": {
-                "has_rows": min_sample_count_df1 > 0,
-                "rows": df_to_str(
-                    self.df1_unq_rows.iloc[:, :min_column_count_df1],
-                    sample_count=min_sample_count_df1,
-                )
-                if self.df1_unq_rows.shape[0] > 0
-                else "",
-                "columns": list(self.df1_unq_rows.columns[:min_column_count_df1])
-                if self.df1_unq_rows.shape[0] > 0
-                else "",
-            },
-            "df2_unique_rows": {
-                "has_rows": min_sample_count_df2 > 0,
-                "rows": df_to_str(
-                    self.df2_unq_rows.iloc[:, :min_column_count_df2],
-                    sample_count=min_sample_count_df2,
-                )
-                if self.df2_unq_rows.shape[0] > 0
-                else "",
-                "columns": list(self.df2_unq_rows.columns[:min_column_count_df2])
-                if self.df2_unq_rows.shape[0] > 0
-                else "",
-            },
-        }
-
-    def report(
-        self,
-        sample_count: int = 10,
-        column_count: int = 10,
-        html_file: str | None = None,
-        template_path: str | None = None,
-    ) -> str:
-        """Return a string representation of a report.
-
-        The representation can then be printed or saved to a file. You can customize the
-        report's appearance by providing a custom Jinja2 template.
-
-        Parameters
-        ----------
-        sample_count : int, optional
-            The number of sample records to return. Defaults to 10.
-
-        column_count : int, optional
-            The number of columns to display in the sample records output. Defaults to 10.
-
-        html_file : str, optional
-            HTML file name to save report output to. If ``None`` the file creation will be skipped.
-
-        template_path : str, optional
-            Path to a custom Jinja2 template file to use for report generation.
-            If ``None``, the default template will be used. The template receives the
-            following context variables:
-
-            - ``column_summary``: Dict with column statistics including: ``common_columns``, ``df1_unique``, ``df2_unique``, ``df1_name``, ``df2_name``
-            - ``row_summary``: Dict with row statistics including: ``match_columns``, ``equal_rows``, ``unequal_rows``
-            - ``column_comparison``: Dict with column comparison statistics including: ``unequal_columns``, ``equal_columns``, ``unequal_values``
-            - ``mismatch_stats``: Dict containing:
-                - ``stats``: List of dicts with column mismatch statistics (column, match, mismatch, null_diff, etc.)
-                - ``samples``: Sample rows with mismatched values
-                - ``has_samples``: Boolean indicating if there are any mismatch samples
-                - ``has_mismatches``: Boolean indicating if there are any mismatches
-            - ``df1_unique_rows``: Dict with unique rows in df1 including: ``has_rows``, ``rows``, ``columns``
-            - ``df2_unique_rows``: Dict with unique rows in df2 including: ``has_rows``, ``rows``, ``columns``
-
-        Returns
-        -------
-        str
-            The report, formatted according to the template.
-
-        Examples
-        --------
-        Basic usage with default template:
-
-        >>> compare = datacompy.Compare(df1, df2, join_columns=['id'])
-        >>> report = compare.report()
-        >>> print(report)
-
-        Using a custom template file:
-
-        >>> # Create a custom template file (custom_report.j2)
-        >>> with open('custom_report.j2', 'w') as f:
-        ...     f.write('''
-        ...     Comparison Report
-        ...     ================
-        ...
-        ...     DataFrames: {{ df1_name }} vs {{ df2_name }}
-        ...
-        ...     Shape Summary:
-        ...     - {{ df1_name }}: {{ df1_shape[0] }} rows x {{ df1_shape[1] }} columns
-        ...     - {{ df2_name }}: {{ df2_shape[0] }} rows x {{ df2_shape[1] }} columns
-        ...
-        ...     {% if mismatch_stats %}
-        ...     Mismatched Columns ({{ mismatch_stats|length }}):
-        ...     {% for col in mismatch_stats %}
-        ...     - {{ col.column }} ({{ col.unequal_cnt }} mismatches)
-        ...     {% endfor %}
-        ...     {% else %}
-        ...     No mismatches found in any columns!
-        ...     {% endif %}
-        ...     ''')
-        ...
-        >>> # Generate report with custom template
-        >>> report = compare.report(template_path='custom_report.j2')
-        >>> print(report)
-        """
-        # Prepare the template data by combining all sections
-        template_data = {
-            **self._get_column_summary(),
-            **self._get_row_summary(),
-            **self._get_column_comparison(),
-            **self._get_mismatch_stats(sample_count),
-            **self._get_unique_rows_data(sample_count, column_count),
-            "df1_name": self.df1_name,
-            "df2_name": self.df2_name,
-            "df1_shape": self.df1.shape,
-            "df2_shape": self.df2.shape,
-            "column_count": column_count,
-        }
-
-        # Determine which template to use
-        template_name = template_path if template_path else "report_template.j2"
-
-        # Render the main report
-        report = render(template_name, **template_data)
-
-        if html_file:
-            save_html_report(report, html_file)
-
-        return report
+    def _column_names(self, df: Any) -> List[str]:
+        return list(df.columns)
 
 
 def columns_equal(
diff --git a/datacompy/polars.py b/datacompy/polars.py
index 2319e35f..dae47a7f 100644
--- a/datacompy/polars.py
+++ b/datacompy/polars.py
@@ -30,10 +30,7 @@
 
 from datacompy.base import (
     BaseCompare,
-    df_to_str,
     get_column_tolerance,
-    render,
-    save_html_report,
     temp_column_name,
     validate_tolerance_parameter,
 )
@@ -703,246 +700,6 @@ def all_mismatch(self, ignore_matching_cols: bool = False) -> pl.DataFrame:
             .select(self.join_columns + return_list)
         )
 
-    def _get_column_summary(self) -> dict:
-        """Generate column summary data for the report.
-
-        Returns
-        -------
-        dict
-            Dictionary containing column summary information.
-        """
-        return {
-            "column_summary": {
-                "common_columns": len(self.intersect_columns()),
-                "df1_unique": f"{len(self.df1_unq_columns())} {self.df1_unq_columns().items}",
-                "df2_unique": f"{len(self.df2_unq_columns())} {self.df2_unq_columns().items}",
-                "df1_name": self.df1_name,
-                "df2_name": self.df2_name,
-            }
-        }
-
-    def _get_row_summary(self) -> dict:
-        """Generate row summary data for the report.
-
-        Returns
-        -------
-        dict
-            Dictionary containing row summary information.
-        """
-        return {
-            "row_summary": {
-                "match_columns": ", ".join(self.join_columns),
-                "abs_tol": self.abs_tol,
-                "rel_tol": self.rel_tol,
-                "common_rows": self.intersect_rows.shape[0],
-                "df1_unique": self.df1_unq_rows.shape[0],
-                "df2_unique": self.df2_unq_rows.shape[0],
-                "unequal_rows": self.intersect_rows.shape[0]
-                - self.count_matching_rows(),
-                "equal_rows": self.count_matching_rows(),
-                "df1_name": self.df1_name,
-                "df2_name": self.df2_name,
-                "has_duplicates": "Yes" if self._any_dupes else "No",
-            }
-        }
-
-    def _get_column_comparison(self) -> dict:
-        """Generate column comparison statistics for the report.
-
-        Returns
-        -------
-        dict
-            Dictionary containing column comparison information.
-        """
-        return {
-            "column_comparison": {
-                "unequal_columns": len(
-                    [col for col in self.column_stats if col["unequal_cnt"] > 0]
-                ),
-                "equal_columns": len(
-                    [col for col in self.column_stats if col["unequal_cnt"] == 0]
-                ),
-                "unequal_values": sum(col["unequal_cnt"] for col in self.column_stats),
-            }
-        }
-
-    def _get_mismatch_stats(self, sample_count: int) -> dict:
-        """Generate mismatch statistics for the report.
-
-        Parameters
-        ----------
-        sample_count : int
-            Number of samples to include in the report.
-
-        Returns
-        -------
-        dict
-            Dictionary containing mismatch statistics.
-        """
-        match_stats = []
-        match_sample = []
-        any_mismatch = False
-
-        for column in self.column_stats:
-            if not column["all_match"]:
-                any_mismatch = True
-                match_stats.append(
-                    {
-                        "column": column["column"],
-                        "dtype1": column["dtype1"],
-                        "dtype2": column["dtype2"],
-                        "unequal_cnt": column["unequal_cnt"],
-                        "max_diff": column["max_diff"],
-                        "null_diff": column["null_diff"],
-                        "rel_tol": column["rel_tol"],
-                        "abs_tol": column["abs_tol"],
-                    }
-                )
-                if column["unequal_cnt"] > 0:
-                    match_sample.append(
-                        self.sample_mismatch(
-                            column["column"], sample_count, for_display=True
-                        )
-                    )
-
-        if any_mismatch:
-            return {
-                "mismatch_stats": {
-                    "has_mismatches": True,
-                    "stats": match_stats,
-                    "df1_name": self.df1_name,
-                    "df2_name": self.df2_name,
-                    "samples": [df_to_str(sample) for sample in match_sample],
-                    "has_samples": len(match_sample) > 0 and sample_count > 0,
-                }
-            }
-        return {
-            "mismatch_stats": {
-                "has_mismatches": False,
-                "has_samples": False,
-            }
-        }
-
-    def _get_unique_rows_data(self, sample_count: int, column_count: int) -> dict:
-        """Generate data for unique rows in both dataframes.
-
-        Parameters
-        ----------
-        sample_count : int
-            Number of samples to include.
-        column_count : int
-            Number of columns to include.
-
-        Returns
-        -------
-        dict
-            Dictionary containing unique rows data for both dataframes.
-        """
-        min_sample_count_df1 = min(sample_count, self.df1_unq_rows.shape[0])
-        min_sample_count_df2 = min(sample_count, self.df2_unq_rows.shape[0])
-        min_column_count_df1 = min(column_count, self.df1_unq_rows.shape[1])
-        min_column_count_df2 = min(column_count, self.df2_unq_rows.shape[1])
-
-        return {
-            "df1_unique_rows": {
-                "has_rows": min_sample_count_df1 > 0,
-                "rows": df_to_str(
-                    self.df1_unq_rows.select(
-                        self.df1_unq_rows.columns[:min_column_count_df1]
-                    ),
-                    sample_count=min_sample_count_df1,
-                )
-                if self.df1_unq_rows.shape[0] > 0
-                else "",
-                "columns": list(self.df1_unq_rows.columns[:min_column_count_df1])
-                if self.df1_unq_rows.shape[0] > 0
-                else "",
-            },
-            "df2_unique_rows": {
-                "has_rows": min_sample_count_df2 > 0,
-                "rows": df_to_str(
-                    self.df2_unq_rows.select(
-                        self.df2_unq_rows.columns[:min_column_count_df2]
-                    ),
-                    sample_count=min_sample_count_df2,
-                )
-                if self.df2_unq_rows.shape[0] > 0
-                else "",
-                "columns": list(self.df2_unq_rows.columns[:min_column_count_df2])
-                if self.df2_unq_rows.shape[0] > 0
-                else "",
-            },
-        }
-
-    def report(
-        self,
-        sample_count: int = 10,
-        column_count: int = 10,
-        html_file: str | None = None,
-        template_path: str | None = None,
-    ) -> str:
-        """Return a string representation of a report.
-
-        The representation can then be printed or saved to a file. You can customize the
-        report's appearance by providing a custom Jinja2 template.
-
-        Parameters
-        ----------
-        sample_count : int, optional
-            The number of sample records to return.  Defaults to 10.
-
-        column_count : int, optional
-            The number of columns to display in the sample records output.  Defaults to 10.
-
-        html_file : str, optional
-            HTML file name to save report output to. If ``None`` the file creation will be skipped.
-
-        template_path : str, optional
-            Path to a custom Jinja2 template file to use for report generation.
-            If ``None``, the default template will be used. The template receives the
-            following context variables:
-
-            - ``column_summary``: Dict with column statistics including: ``common_columns``, ``df1_unique``, ``df2_unique``, ``df1_name``, ``df2_name``
-            - ``row_summary``: Dict with row statistics including: ``match_columns``, ``equal_rows``, ``unequal_rows``
-            - ``column_comparison``: Dict with column comparison statistics including: ``unequal_columns``, ``equal_columns``, ``unequal_values``
-            - ``mismatch_stats``: Dict containing:
-                - ``stats``: List of dicts with column mismatch statistics (column, match, mismatch, null_diff, etc.)
-                - ``samples``: Sample rows with mismatched values
-                - ``has_samples``: Boolean indicating if there are any mismatch samples
-                - ``has_mismatches``: Boolean indicating if there are any mismatches
-            - ``df1_unique_rows``: Dict with unique rows in df1 including: ``has_rows``, ``rows``, ``columns``
-            - ``df2_unique_rows``: Dict with unique rows in df2 including: ``has_rows``, ``rows``, ``columns``
-
-        Returns
-        -------
-        str
-            The report, formatted according to the template.
-        """
-        # Prepare template data
-        template_data = {
-            **self._get_column_summary(),
-            **self._get_row_summary(),
-            **self._get_column_comparison(),
-            **self._get_mismatch_stats(sample_count),
-            **self._get_unique_rows_data(sample_count, column_count),
-            "df1_name": self.df1_name,
-            "df2_name": self.df2_name,
-            "df1_shape": (self.df1.shape[0], self.df1.shape[1]),
-            "df2_shape": (self.df2.shape[0], self.df2.shape[1]),
-            "column_count": column_count,
-        }
-
-        # Determine which template to use
-        template_name = template_path if template_path else "report_template.j2"
-
-        # Render the main report
-        report = render(template_name, **template_data)
-
-        if html_file:
-            save_html_report(report, html_file)
-
-        return report
-
 
 def columns_equal(
     col_1: pl.Series,
diff --git a/datacompy/snowflake.py b/datacompy/snowflake.py
index 574b6d5a..8dd9015b 100644
--- a/datacompy/snowflake.py
+++ b/datacompy/snowflake.py
@@ -58,10 +58,7 @@
 
 from datacompy.base import (
     BaseCompare,
-    df_to_str,
     get_column_tolerance,
-    render,
-    save_html_report,
     validate_tolerance_parameter,
 )
 from datacompy.comparator import (
@@ -903,256 +900,22 @@ def all_mismatch(self, ignore_matching_cols: bool = False) -> "sp.DataFrame":
 
         return mm_rows.select(self.join_columns + return_list)
 
-    def _get_column_summary(self) -> dict:
-        """Generate column summary data for the report.
+    def _table_shape(self, df: Any) -> tuple[int, int]:
+        return (df.count(), len(df.columns))
 
-        Returns
-        -------
-        dict
-            Dictionary containing column summary information.
-        """
-        return {
-            "column_summary": {
-                "common_columns": len(self.intersect_columns()),
-                "df1_unique": f"{len(self.df1_unq_columns())} {self.df1_unq_columns().items}",
-                "df2_unique": f"{len(self.df2_unq_columns())} {self.df2_unq_columns().items}",
-                "df1_name": self.df1_name,
-                "df2_name": self.df2_name,
-            }
-        }
+    def _row_count(self, df: Any, _cache: Dict[int, int] | None = None) -> int:
+        if _cache is not None:
+            key = id(df)
+            if key not in _cache:
+                _cache[key] = df.count()
+            return _cache[key]
+        return int(df.count())
 
-    def _get_row_summary(self) -> dict:
-        """Generate row summary data for the report.
+    def _select_first_n_columns(self, df: Any, n: int) -> Any:
+        return df.select(df.columns[:n])
 
-        Returns
-        -------
-        dict
-            Dictionary containing row summary information.
-        """
-        intersect_count = self.intersect_rows.count()
-        df1_unq_count = self.df1_unq_rows.count()
-        df2_unq_count = self.df2_unq_rows.count()
-        matching_rows = self.count_matching_rows()
-
-        return {
-            "row_summary": {
-                "match_columns": ", ".join(self.join_columns),
-                "abs_tol": self.abs_tol,
-                "rel_tol": self.rel_tol,
-                "common_rows": intersect_count,
-                "df1_unique": df1_unq_count,
-                "df2_unique": df2_unq_count,
-                "unequal_rows": intersect_count - matching_rows,
-                "equal_rows": matching_rows,
-                "df1_name": self.df1_name,
-                "df2_name": self.df2_name,
-                "has_duplicates": "Yes" if self._any_dupes else "No",
-            }
-        }
-
-    def _get_column_comparison(self) -> dict:
-        """Generate column comparison statistics for the report.
-
-        Returns
-        -------
-        dict
-            Dictionary containing column comparison information.
-        """
-        return {
-            "column_comparison": {
-                "unequal_columns": len(
-                    [c for c in self.column_stats if c["unequal_cnt"] > 0]
-                ),
-                "equal_columns": len(
-                    [c for c in self.column_stats if c["unequal_cnt"] == 0]
-                ),
-                "unequal_values": sum(c["unequal_cnt"] for c in self.column_stats),
-            }
-        }
-
-    def _get_mismatch_stats(self, sample_count: int) -> dict:
-        """Generate mismatch statistics for the report.
-
-        Parameters
-        ----------
-        sample_count : int
-            Number of samples to include in the report.
-
-        Returns
-        -------
-        dict
-            Dictionary containing mismatch statistics.
-        """
-        match_stats = []
-        match_sample = []
-        any_mismatch = False
-
-        for column in self.column_stats:
-            if not column["all_match"]:
-                any_mismatch = True
-                match_stats.append(
-                    {
-                        "column": column["column"],
-                        "dtype1": column["dtype1"],
-                        "dtype2": column["dtype2"],
-                        "unequal_cnt": column["unequal_cnt"],
-                        "max_diff": column["max_diff"],
-                        "null_diff": column["null_diff"],
-                        "rel_tol": column["rel_tol"],
-                        "abs_tol": column["abs_tol"],
-                    }
-                )
-                if column["unequal_cnt"] > 0:
-                    match_sample.append(
-                        self.sample_mismatch(
-                            column["column"], sample_count, for_display=True
-                        )
-                    )
-
-        if any_mismatch:
-            return {
-                "mismatch_stats": {
-                    "has_mismatches": True,
-                    "stats": match_stats,
-                    "df1_name": self.df1_name,
-                    "df2_name": self.df2_name,
-                    "samples": [df_to_str(sample) for sample in match_sample],
-                    "has_samples": len(match_sample) > 0 and sample_count > 0,
-                }
-            }
-        return {
-            "mismatch_stats": {
-                "has_mismatches": False,
-                "has_samples": False,
-            }
-        }
-
-    def _get_unique_rows_data(self, sample_count: int, column_count: int) -> dict:
-        """Generate data for unique rows in both dataframes.
-
-        Parameters
-        ----------
-        sample_count : int
-            Number of samples to include.
-        column_count : int
-            Number of columns to include.
-
-        Returns
-        -------
-        dict
-            Dictionary containing unique rows data for both dataframes.
-        """
-        df1_unq_count = self.df1_unq_rows.count()
-        df2_unq_count = self.df2_unq_rows.count()
-
-        min_sample_count_df1 = min(sample_count, df1_unq_count)
-        min_sample_count_df2 = min(sample_count, df2_unq_count)
-        min_column_count_df1 = min(column_count, len(self.df1_unq_rows.columns))
-        min_column_count_df2 = min(column_count, len(self.df2_unq_rows.columns))
-
-        return {
-            "df1_unique_rows": {
-                "has_rows": min_sample_count_df1 > 0,
-                "rows": df_to_str(
-                    self.df1_unq_rows.select(
-                        self.df1_unq_rows.columns[:min_column_count_df1]
-                    ),
-                    sample_count=min_sample_count_df1,
-                )
-                if df1_unq_count > 0
-                else "",
-                "columns": list(self.df1_unq_rows.columns[:min_column_count_df1])
-                if df1_unq_count > 0
-                else "",
-            },
-            "df2_unique_rows": {
-                "has_rows": min_sample_count_df2 > 0,
-                "rows": df_to_str(
-                    self.df2_unq_rows.select(
-                        self.df2_unq_rows.columns[:min_column_count_df2]
-                    ),
-                    sample_count=min_sample_count_df2,
-                )
-                if df2_unq_count > 0
-                else "",
-                "columns": list(self.df2_unq_rows.columns[:min_column_count_df2])
-                if df2_unq_count > 0
-                else "",
-            },
-        }
-
-    def report(
-        self,
-        sample_count: int = 10,
-        column_count: int = 10,
-        html_file: str | None = None,
-        template_path: str | None = None,
-    ) -> str:
-        """Return a string representation of a report.
-
-        The representation can then be printed or saved to a file. You can customize the
-        report's appearance by providing a custom Jinja2 template.
-
-        Parameters
-        ----------
-        sample_count : int, optional
-            The number of sample records to return. Defaults to 10.
-
-        column_count : int, optional
-            The number of columns to display in the sample records output. Defaults to 10.
-
-        html_file : str, optional
-            HTML file name to save report output to. If ``None`` the file creation will be skipped.
-
-        template_path : str, optional
-            Path to a custom Jinja2 template file to use for report generation.
-            If ``None``, the default template will be used. The template receives the
-            following context variables:
-
-            - ``column_summary``: Dict with column statistics including: ``common_columns``, ``df1_unique``, ``df2_unique``, ``df1_name``, ``df2_name``
-            - ``row_summary``: Dict with row statistics including: ``match_columns``, ``equal_rows``, ``unequal_rows``
-            - ``column_comparison``: Dict with column comparison statistics including: ``unequal_columns``, ``equal_columns``, ``unequal_values``
-            - ``mismatch_stats``: Dict containing:
-                - ``stats``: List of dicts with column mismatch statistics (column, match, mismatch, null_diff, etc.)
-                - ``samples``: Sample rows with mismatched values
-                - ``has_samples``: Boolean indicating if there are any mismatch samples
-                - ``has_mismatches``: Boolean indicating if there are any mismatches
-            - ``df1_unique_rows``: Dict with unique rows in df1 including: ``has_rows``, ``rows``, ``columns``
-            - ``df2_unique_rows``: Dict with unique rows in df2 including: ``has_rows``, ``rows``, ``columns``
-
-        Returns
-        -------
-        str
-            The report, formatted according to the template.
-        """
-        # Get counts for the dataframes
-        df1_count = self.df1.count()
-        df2_count = self.df2.count()
-
-        # Prepare template data
-        template_data = {
-            **self._get_column_summary(),
-            **self._get_row_summary(),
-            **self._get_column_comparison(),
-            **self._get_mismatch_stats(sample_count),
-            **self._get_unique_rows_data(sample_count, column_count),
-            "df1_name": self.df1_name,
-            "df2_name": self.df2_name,
-            "df1_shape": (df1_count, len(self.df1.columns)),
-            "df2_shape": (df2_count, len(self.df2.columns)),
-            "column_count": column_count,
-        }
-
-        # Determine which template to use
-        template_name = template_path if template_path else "report_template.j2"
-
-        # Render the main report
-        report = render(template_name, **template_data)
-
-        if html_file:
-            save_html_report(report, html_file)
-
-        return report
+    def _column_names(self, df: Any) -> List[str]:
+        return list(df.columns)
 
 
 def columns_equal(
diff --git a/datacompy/spark.py b/datacompy/spark.py
index 5812dd1f..f3cdc5ad 100644
--- a/datacompy/spark.py
+++ b/datacompy/spark.py
@@ -34,10 +34,7 @@
 
 from datacompy.base import (
     BaseCompare,
-    df_to_str,
     get_column_tolerance,
-    render,
-    save_html_report,
     temp_column_name,
     validate_tolerance_parameter,
 )
@@ -866,266 +863,22 @@ def all_mismatch(
 
         return mm_rows.select(self.join_columns + return_list)
 
-    def _get_column_summary(self) -> dict:
-        """Generate column summary data for the report.
+    def _table_shape(self, df: Any) -> tuple[int, int]:
+        return (df.count(), len(df.columns))
 
-        Returns
-        -------
-        dict
-            Dictionary containing column summary information.
-        """
-        return {
-            "column_summary": {
-                "common_columns": len(self.intersect_columns()),
-                "df1_unique": f"{len(self.df1_unq_columns())} {self.df1_unq_columns().items}",
-                "df2_unique": f"{len(self.df2_unq_columns())} {self.df2_unq_columns().items}",
-                "df1_name": self.df1_name,
-                "df2_name": self.df2_name,
-            }
-        }
-
-    def _get_row_summary(self) -> dict:
-        """Generate row summary data for the report.
+    def _row_count(self, df: Any, _cache: Dict[int, int] | None = None) -> int:
+        if _cache is not None:
+            key = id(df)
+            if key not in _cache:
+                _cache[key] = df.count()
+            return _cache[key]
+        return int(df.count())
 
-        Returns
-        -------
-        dict
-            Dictionary containing row summary information.
-        """
-        intersect_count = self.intersect_rows.count()
-        df1_unq_count = self.df1_unq_rows.count()
-        df2_unq_count = self.df2_unq_rows.count()
-        matching_rows = self.count_matching_rows()
-
-        return {
-            "row_summary": {
-                "match_columns": ", ".join(self.join_columns),
-                "abs_tol": self.abs_tol,
-                "rel_tol": self.rel_tol,
-                "common_rows": intersect_count,
-                "df1_unique": df1_unq_count,
-                "df2_unique": df2_unq_count,
-                "unequal_rows": intersect_count - matching_rows,
-                "equal_rows": matching_rows,
-                "df1_name": self.df1_name,
-                "df2_name": self.df2_name,
-                "has_duplicates": "Yes" if self._any_dupes else "No",
-            }
-        }
-
-    def _get_column_comparison(self) -> dict:
-        """Generate column comparison statistics for the report.
+    def _select_first_n_columns(self, df: Any, n: int) -> Any:
+        return df.select(df.columns[:n])
 
-        Returns
-        -------
-        dict
-            Dictionary containing column comparison information.
-        """
-        return {
-            "column_comparison": {
-                "unequal_columns": len(
-                    [col for col in self.column_stats if col["unequal_cnt"] > 0]
-                ),
-                "equal_columns": len(
-                    [col for col in self.column_stats if col["unequal_cnt"] == 0]
-                ),
-                "unequal_values": sum(col["unequal_cnt"] for col in self.column_stats),
-            }
-        }
-
-    def _get_mismatch_stats(self, sample_count: int) -> dict:
-        """Generate mismatch statistics for the report.
-
-        Parameters
-        ----------
-        sample_count : int
-            Number of samples to include in the report.
-
-        Returns
-        -------
-        dict
-            Dictionary containing mismatch statistics.
-        """
-        match_stats = []
-        match_sample = []
-        any_mismatch = False
-
-        for column in self.column_stats:
-            if not column["all_match"]:
-                any_mismatch = True
-                match_stats.append(
-                    {
-                        "column": column["column"],
-                        "dtype1": column["dtype1"],
-                        "dtype2": column["dtype2"],
-                        "unequal_cnt": column["unequal_cnt"],
-                        "max_diff": column["max_diff"],
-                        "null_diff": column["null_diff"],
-                        "rel_tol": column["rel_tol"],
-                        "abs_tol": column["abs_tol"],
-                    }
-                )
-                if column["unequal_cnt"] > 0:
-                    match_sample.append(
-                        self.sample_mismatch(
-                            column["column"], sample_count, for_display=True
-                        )
-                    )
-
-        if any_mismatch:
-            return {
-                "mismatch_stats": {
-                    "has_mismatches": True,
-                    "stats": match_stats,
-                    "df1_name": self.df1_name,
-                    "df2_name": self.df2_name,
-                    "samples": [df_to_str(sample) for sample in match_sample],
-                    "has_samples": len(match_sample) > 0 and sample_count > 0,
-                }
-            }
-        return {
-            "mismatch_stats": {
-                "has_mismatches": False,
-                "has_samples": False,
-            }
-        }
-
-    def _get_unique_rows_data(self, sample_count: int, column_count: int) -> dict:
-        """Generate data for unique rows in both dataframes.
-
-        Parameters
-        ----------
-        sample_count : int
-            Number of samples to include.
-        column_count : int
-            Number of columns to include.
-
-        Returns
-        -------
-        dict
-            Dictionary containing unique rows data for both dataframes.
-        """
-        df1_unq_count = self.df1_unq_rows.count()
-        df2_unq_count = self.df2_unq_rows.count()
-
-        min_sample_count_df1 = min(sample_count, df1_unq_count)
-        min_sample_count_df2 = min(sample_count, df2_unq_count)
-        min_column_count_df1 = min(column_count, len(self.df1_unq_rows.columns))
-        min_column_count_df2 = min(column_count, len(self.df2_unq_rows.columns))
-
-        return {
-            "sample_count": sample_count,
-            "column_count": column_count,
-            "df1_unique_rows": {
-                "has_rows": min_sample_count_df1 > 0,
-                "rows": (
-                    df_to_str(
-                        self.df1_unq_rows.select(
-                            self.df1_unq_rows.columns[:min_column_count_df1]
-                        ),
-                        sample_count=min_sample_count_df1,
-                    )
-                    if df1_unq_count > 0
-                    else ""
-                ),
-                "columns": (
-                    self.df1_unq_rows.columns[:min_column_count_df1]
-                    if df1_unq_count > 0
-                    else ""
-                ),
-            },
-            "df2_unique_rows": {
-                "has_rows": min_sample_count_df2 > 0,
-                "rows": (
-                    df_to_str(
-                        self.df2_unq_rows.select(
-                            self.df2_unq_rows.columns[:min_column_count_df2]
-                        ),
-                        sample_count=min_sample_count_df2,
-                    )
-                    if df2_unq_count > 0
-                    else ""
-                ),
-                "columns": (
-                    self.df2_unq_rows.columns[:min_column_count_df2]
-                    if df2_unq_count > 0
-                    else ""
-                ),
-            },
-        }
-
-    def report(
-        self,
-        sample_count: int = 10,
-        column_count: int = 10,
-        html_file: str | None = None,
-        template_path: str | None = None,
-    ) -> str:
-        """Return a string representation of a report.
-
-        The representation can then be printed or saved to a file. You can customize the
-        report's appearance by providing a custom Jinja2 template.
-
-        Parameters
-        ----------
-        sample_count : int, optional
-            The number of sample records to return. Defaults to 10.
-
-        column_count : int, optional
-            The number of columns to display in the sample records output. Defaults to 10.
-
-        html_file : str, optional
-            HTML file name to save report output to. If ``None`` the file creation will be skipped.
-
-        template_path : str, optional
-            Path to a custom Jinja2 template file to use for report generation.
-            If ``None``, the default template will be used. The template receives the
-            following context variables:
-
-            - ``column_summary``: Dict with column statistics including: ``common_columns``, ``df1_unique``, ``df2_unique``, ``df1_name``, ``df2_name``
-            - ``row_summary``: Dict with row statistics including: ``match_columns``, ``equal_rows``, ``unequal_rows``
-            - ``column_comparison``: Dict with column comparison statistics including: ``unequal_columns``, ``equal_columns``, ``unequal_values``
-            - ``mismatch_stats``: Dict containing:
-                - ``stats``: List of dicts with column mismatch statistics (column, match, mismatch, null_diff, etc.)
-                - ``samples``: Sample rows with mismatched values
-                - ``has_samples``: Boolean indicating if there are any mismatch samples
-                - ``has_mismatches``: Boolean indicating if there are any mismatches
-            - ``df1_unique_rows``: Dict with unique rows in df1 including: ``has_rows``, ``rows``, ``columns``
-            - ``df2_unique_rows``: Dict with unique rows in df2 including: ``has_rows``, ``rows``, ``columns``
-
-        Returns
-        -------
-        str
-            The report, formatted according to the template.
-        """
-        # Get counts for the dataframes
-        df1_count = self.df1.count()
-        df2_count = self.df2.count()
-
-        # Prepare template data
-        template_data: Dict[str, Any] = {
-            **self._get_column_summary(),
-            **self._get_row_summary(),
-            **self._get_column_comparison(),
-            **self._get_mismatch_stats(sample_count),
-            **self._get_unique_rows_data(sample_count, column_count),
-            "df1_name": self.df1_name,
-            "df2_name": self.df2_name,
-            "df1_shape": (df1_count, len(self.df1.columns)),
-            "df2_shape": (df2_count, len(self.df2.columns)),
-            "column_count": column_count,
-        }
-
-        # Determine which template to use
-        template_name = template_path if template_path else "report_template.j2"
-
-        # Render the main report
-        report = render(template_name, **template_data)
-
-        if html_file:
-            save_html_report(report, html_file)
-
-        return report
+    def _column_names(self, df: Any) -> List[str]:
+        return list(df.columns)
 
 
 def columns_equal(

From ffa9a95737f1e58193e021ed35a106b158cfd64c Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Tue, 16 Jun 2026 22:38:54 -0400
Subject: [PATCH 07/23] feat: add programmatic report access and new Report
 class for structured comparison data

---
 README.md             |  31 ++++
 datacompy/__init__.py |  18 ++
 datacompy/base.py     | 215 +++++++++++++++++++++-
 datacompy/report.py   | 411 ++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 669 insertions(+), 6 deletions(-)
 create mode 100644 datacompy/report.py

diff --git a/README.md b/README.md
index 60dd2f76..7c397570 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,37 @@ pip install datacompy[snowflake]
 - Snowflake/Snowpark: ([See documentation](https://capitalone.github.io/datacompy/snowflake_usage.html))
 
 
+## Programmatic Report Access
+
+Every compare object exposes `build_report_data()` which returns a typed
+[`ReportData`](https://capitalone.github.io/datacompy/report_api.html) object
+— useful for dashboards, JSON export, or custom rendering without relying on
+the string report:
+
+```python
+import pandas as pd
+from datacompy import PandasCompare, Report
+
+df1 = pd.DataFrame({"id": [1, 2, 3], "val": [10, 20, 30]})
+df2 = pd.DataFrame({"id": [1, 2, 3], "val": [10, 99, 30]})
+
+compare = PandasCompare(df1, df2, join_columns="id")
+
+# Access structured data directly
+data = compare.build_report_data()
+print(data.row_summary.unequal_rows)        # 1
+print(data.mismatch_stats.stats[0].column)  # 'val'
+
+# Render / export
+rep = Report(data)
+print(rep.render())        # same text as compare.report()
+rep.save("report.html")   # HTML file
+rep.to_dict()              # JSON-serializable dict
+```
+
+See the [Report API documentation](https://capitalone.github.io/datacompy/report_api.html) for the full reference.
+
+
 ## Contributors
 
 We welcome and appreciate your contributions! Before we can accept any contributions, we ask that you please be sure to
diff --git a/datacompy/__init__.py b/datacompy/__init__.py
index e392fccf..be667bbc 100644
--- a/datacompy/__init__.py
+++ b/datacompy/__init__.py
@@ -23,11 +23,29 @@
 from datacompy.base import BaseCompare
 from datacompy.pandas import PandasCompare
 from datacompy.polars import PolarsCompare
+from datacompy.report import (
+    ColumnComparison,
+    ColumnSummary,
+    MismatchStat,
+    MismatchStats,
+    Report,
+    ReportData,
+    RowSummary,
+    UniqueRowsData,
+)
 
 __all__ = [
     "BaseCompare",
+    "ColumnComparison",
+    "ColumnSummary",
+    "MismatchStat",
+    "MismatchStats",
     "PandasCompare",
     "PolarsCompare",
+    "Report",
+    "ReportData",
+    "RowSummary",
+    "UniqueRowsData",
 ]
 
 try:
diff --git a/datacompy/base.py b/datacompy/base.py
index 22274c8d..41b902b0 100644
--- a/datacompy/base.py
+++ b/datacompy/base.py
@@ -191,7 +191,197 @@ def all_mismatch(self, ignore_matching_cols: bool = False) -> Any:
         """Get all rows that mismatch."""
         pass
 
-    @abstractmethod
+    # ------------------------------------------------------------------
+    # Backend primitives — override in subclasses where the default
+    # (.shape / .columns) does not apply (e.g. Spark / Snowflake).
+    # ------------------------------------------------------------------
+
+    def _table_shape(self, df: Any) -> tuple[int, int]:
+        """Return ``(rows, columns)`` for *df*.
+
+        Spark and Snowflake backends override this to call ``.count()``.
+        """
+        return df.shape  # type: ignore[no-any-return]
+
+    def _row_count(self, df: Any, _cache: Dict[int, int] | None = None) -> int:
+        """Return the row count for *df*, using *_cache* when provided.
+
+        Parameters
+        ----------
+        df :
+            The DataFrame whose rows to count.
+        _cache : dict, optional
+            Mapping from ``id(df)`` to cached count.  Spark and Snowflake
+            backends pass this in to avoid redundant ``.count()`` actions.
+        """
+        if _cache is not None:
+            key = id(df)
+            if key not in _cache:
+                _cache[key] = int(df.shape[0])
+            return _cache[key]
+        return int(df.shape[0])
+
+    def _select_first_n_columns(self, df: Any, n: int) -> Any:
+        """Return *df* with only its first *n* columns.
+
+        Pandas overrides this with ``.iloc[:, :n]``.
+        """
+        return df.select(list(df.columns)[:n])
+
+    def _column_names(self, df: Any) -> List[str]:
+        """Return column names of *df* as a plain list."""
+        return list(df.columns)  # type: ignore[return-value]
+
+    # ------------------------------------------------------------------
+    # Concrete report-building — shared across all backends
+    # ------------------------------------------------------------------
+
+    def build_report_data(self, sample_count: int = 10, column_count: int = 10) -> Any:
+        """Build a typed :class:`~datacompy.report.ReportData` from this comparison.
+
+        Parameters
+        ----------
+        sample_count : int, optional
+            Maximum number of sample rows to include per section. Defaults to 10.
+        column_count : int, optional
+            Maximum number of columns to show in unique-row samples. Defaults to 10.
+
+        Returns
+        -------
+        datacompy.report.ReportData
+            Immutable data object suitable for rendering or programmatic use.
+
+        Examples
+        --------
+        >>> data = compare.build_report_data()
+        >>> print(data.row_summary.unequal_rows)
+        """
+        from datacompy.report import (
+            ColumnComparison,
+            ColumnSummary,
+            MismatchStat,
+            MismatchStats,
+            ReportData,
+            RowSummary,
+            UniqueRowsData,
+        )
+
+        # Per-call row-count cache so Spark/Snowflake only trigger .count() once
+        # per unique DataFrame object.
+        row_count_cache: Dict[int, int] = {}
+
+        # ---- column summary ------------------------------------------
+        df1_unq_cols = self.df1_unq_columns()
+        df2_unq_cols = self.df2_unq_columns()
+        column_summary = ColumnSummary(
+            common_columns=len(self.intersect_columns()),
+            df1_unique=len(df1_unq_cols),
+            df1_unique_columns=tuple(df1_unq_cols),
+            df2_unique=len(df2_unq_cols),
+            df2_unique_columns=tuple(df2_unq_cols),
+            df1_name=self.df1_name,
+            df2_name=self.df2_name,
+        )
+
+        # ---- row summary ---------------------------------------------
+        intersect_count = self._row_count(self.intersect_rows, row_count_cache)
+        df1_unq_count = self._row_count(self.df1_unq_rows, row_count_cache)
+        df2_unq_count = self._row_count(self.df2_unq_rows, row_count_cache)
+        matching_rows = self.count_matching_rows()
+
+        on_index: bool = getattr(self, "on_index", False)
+        row_summary = RowSummary(
+            match_columns=tuple(self.join_columns),
+            on_index=on_index,
+            has_duplicates=bool(self._any_dupes),
+            abs_tol=self.abs_tol,
+            rel_tol=self.rel_tol,
+            common_rows=intersect_count,
+            df1_unique=df1_unq_count,
+            df2_unique=df2_unq_count,
+            unequal_rows=intersect_count - matching_rows,
+            equal_rows=matching_rows,
+            df1_name=self.df1_name,
+            df2_name=self.df2_name,
+        )
+
+        # ---- column comparison ---------------------------------------
+        column_comparison = ColumnComparison(
+            unequal_columns=len([c for c in self.column_stats if c["unequal_cnt"] > 0]),
+            equal_columns=len([c for c in self.column_stats if c["unequal_cnt"] == 0]),
+            unequal_values=sum(c["unequal_cnt"] for c in self.column_stats),
+        )
+
+        # ---- mismatch stats ------------------------------------------
+        stat_list: List[MismatchStat] = []
+        sample_list: List[Any] = []
+        any_mismatch = False
+
+        for col in self.column_stats:
+            if not col["all_match"]:
+                any_mismatch = True
+                stat_list.append(
+                    MismatchStat(
+                        column=col["column"],
+                        dtype1=col["dtype1"],
+                        dtype2=col["dtype2"],
+                        unequal_cnt=col["unequal_cnt"],
+                        max_diff=col["max_diff"],
+                        null_diff=col["null_diff"],
+                        rel_tol=col["rel_tol"],
+                        abs_tol=col["abs_tol"],
+                    )
+                )
+                if col["unequal_cnt"] > 0:
+                    sample_list.append(
+                        self.sample_mismatch(
+                            col["column"], sample_count, for_display=True
+                        )
+                    )
+
+        if any_mismatch:
+            mismatch_stats = MismatchStats(
+                has_mismatches=True,
+                has_samples=len(sample_list) > 0 and sample_count > 0,
+                stats=tuple(sorted(stat_list, key=lambda s: s.column)),
+                samples=tuple(df_to_str(s) for s in sample_list),
+                df1_name=self.df1_name,
+                df2_name=self.df2_name,
+            )
+        else:
+            mismatch_stats = MismatchStats(has_mismatches=False, has_samples=False)
+
+        # ---- unique rows data ----------------------------------------
+        def _unique_rows_data(df: Any, unq_count: int) -> "UniqueRowsData":
+            min_sample = min(sample_count, unq_count)
+            min_cols = min(column_count, len(self._column_names(df)))
+            if unq_count > 0:
+                rows_str = df_to_str(
+                    self._select_first_n_columns(df, min_cols),
+                    sample_count=min_sample,
+                )
+            else:
+                rows_str = ""
+            return UniqueRowsData(has_rows=min_sample > 0, rows=rows_str)
+
+        df1_unique_rows = _unique_rows_data(self.df1_unq_rows, df1_unq_count)
+        df2_unique_rows = _unique_rows_data(self.df2_unq_rows, df2_unq_count)
+
+        # ---- assemble ------------------------------------------------
+        return ReportData(
+            df1_name=self.df1_name,
+            df2_name=self.df2_name,
+            df1_shape=self._table_shape(self.df1),
+            df2_shape=self._table_shape(self.df2),
+            column_count=column_count,
+            column_summary=column_summary,
+            row_summary=row_summary,
+            column_comparison=column_comparison,
+            mismatch_stats=mismatch_stats,
+            df1_unique_rows=df1_unique_rows,
+            df2_unique_rows=df2_unique_rows,
+        )
+
     def report(
         self,
         sample_count: int = 10,
@@ -201,27 +391,40 @@ def report(
     ) -> str:
         """Return a string representation of a report.
 
+        The representation can then be printed or saved to a file. You can customize the
+        report's appearance by providing a custom Jinja2 template.
+
         Parameters
         ----------
         sample_count : int, optional
             The number of sample records to return. Defaults to 10.
-
         column_count : int, optional
             The number of columns to display in the sample records output. Defaults to 10.
-
         html_file : str, optional
             HTML file name to save report output to. If ``None`` the file creation will be skipped.
-
         template_path : str, optional
             Path to a custom Jinja2 template file to use for report generation.
-            If ``None``, the default template will be used.
+            If ``None``, the default template will be used. The template receives the
+            context variables documented on :class:`~datacompy.report.ReportData`.
 
         Returns
         -------
         str
             The report, formatted according to the template.
+
+        See Also
+        --------
+        build_report_data : Access the structured data without rendering.
+        datacompy.report.Report : Render / export a :class:`~datacompy.report.ReportData`.
         """
-        pass
+        from datacompy.report import Report
+
+        data = self.build_report_data(sample_count, column_count)
+        rep = Report(data, template_path=template_path)
+        text = rep.render()
+        if html_file:
+            save_html_report(text, html_file)
+        return text
 
     def reveal_sensitive_columns(self) -> None:
         """Reveals all sensitive columns.
diff --git a/datacompy/report.py b/datacompy/report.py
new file mode 100644
index 00000000..bbb2b425
--- /dev/null
+++ b/datacompy/report.py
@@ -0,0 +1,411 @@
+#
+# Copyright 2026 Capital One Services, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Typed data model and Report class for DataComPy report generation.
+
+All backends produce a :class:`ReportData` instance via
+``compare.build_report_data()``.  :class:`Report` owns rendering to text
+and HTML, and exposes ``to_dict()`` for programmatic consumers.
+
+Examples
+--------
+Programmatic access without rendering:
+
+>>> data = compare.build_report_data()
+>>> print(data.row_summary.unequal_rows)
+>>> print(data.mismatch_stats.stats[0].column)
+
+Render to text and save HTML:
+
+>>> from datacompy import Report
+>>> data = compare.build_report_data()
+>>> rep = Report(data)
+>>> print(rep.render())
+>>> rep.save("comparison.html")
+
+Export as JSON-serializable dict:
+
+>>> import json
+>>> json.dumps(rep.to_dict())
+"""
+
+import dataclasses
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict, Tuple
+
+from datacompy.base import render, save_html_report
+
+
+@dataclass(frozen=True)
+class ColumnSummary:
+    """Summary of column overlap between the two DataFrames.
+
+    Attributes
+    ----------
+    common_columns : int
+        Number of columns present in both DataFrames.
+    df1_unique : int
+        Number of columns only in df1.
+    df1_unique_columns : tuple of str
+        Names of columns only in df1.
+    df2_unique : int
+        Number of columns only in df2.
+    df2_unique_columns : tuple of str
+        Names of columns only in df2.
+    df1_name : str
+        Label for the first DataFrame.
+    df2_name : str
+        Label for the second DataFrame.
+    """
+
+    common_columns: int
+    df1_unique: int
+    df1_unique_columns: Tuple[str, ...]
+    df2_unique: int
+    df2_unique_columns: Tuple[str, ...]
+    df1_name: str
+    df2_name: str
+
+
+@dataclass(frozen=True)
+class RowSummary:
+    """Summary of row overlap and match statistics.
+
+    Attributes
+    ----------
+    match_columns : tuple of str
+        Column(s) used to join/match rows.  Empty tuple when matching on index.
+    on_index : bool
+        True when the comparison was joined on DataFrame index rather than columns.
+    has_duplicates : bool
+        Whether duplicate join-key values were found.
+    abs_tol : float or dict
+        Absolute tolerance used for numeric comparisons.
+    rel_tol : float or dict
+        Relative tolerance used for numeric comparisons.
+    common_rows : int
+        Number of rows present in both DataFrames after joining.
+    df1_unique : int
+        Number of rows only in df1.
+    df2_unique : int
+        Number of rows only in df2.
+    unequal_rows : int
+        Number of common rows where at least one compared column differs.
+    equal_rows : int
+        Number of common rows where all compared columns match.
+    df1_name : str
+        Label for the first DataFrame.
+    df2_name : str
+        Label for the second DataFrame.
+    """
+
+    match_columns: Tuple[str, ...]
+    on_index: bool
+    has_duplicates: bool
+    abs_tol: Any
+    rel_tol: Any
+    common_rows: int
+    df1_unique: int
+    df2_unique: int
+    unequal_rows: int
+    equal_rows: int
+    df1_name: str
+    df2_name: str
+
+
+@dataclass(frozen=True)
+class ColumnComparison:
+    """Aggregate column comparison counts.
+
+    Attributes
+    ----------
+    unequal_columns : int
+        Number of compared columns with at least one unequal value.
+    equal_columns : int
+        Number of compared columns where all values matched.
+    unequal_values : int
+        Total number of individual cell values that did not match.
+    """
+
+    unequal_columns: int
+    equal_columns: int
+    unequal_values: int
+
+
+@dataclass(frozen=True)
+class MismatchStat:
+    """Per-column mismatch statistics.
+
+    Attributes
+    ----------
+    column : str
+        Column name.
+    dtype1 : str
+        Data type in df1.
+    dtype2 : str
+        Data type in df2.
+    unequal_cnt : int
+        Number of rows where the values differ.
+    max_diff : float
+        Maximum absolute numeric difference (0.0 for non-numeric columns).
+    null_diff : int
+        Number of rows where one value is null and the other is not.
+    rel_tol : float
+        Relative tolerance applied to this column.
+    abs_tol : float
+        Absolute tolerance applied to this column.
+    """
+
+    column: str
+    dtype1: str
+    dtype2: str
+    unequal_cnt: int
+    max_diff: float
+    null_diff: int
+    rel_tol: float
+    abs_tol: float
+
+
+@dataclass(frozen=True)
+class MismatchStats:
+    """Mismatch statistics across all compared columns.
+
+    Attributes
+    ----------
+    has_mismatches : bool
+        True when at least one column has unequal values or types.
+    has_samples : bool
+        True when sample rows are available for display.
+    stats : tuple of MismatchStat
+        Per-column statistics, sorted by column name.
+    samples : tuple of str
+        Pre-rendered string tables of sample mismatched rows (one per column).
+    df1_name : str
+        Label for the first DataFrame.
+    df2_name : str
+        Label for the second DataFrame.
+    """
+
+    has_mismatches: bool
+    has_samples: bool
+    stats: Tuple[MismatchStat, ...] = ()
+    samples: Tuple[str, ...] = ()
+    df1_name: str = ""
+    df2_name: str = ""
+
+
+@dataclass(frozen=True)
+class UniqueRowsData:
+    """Sample rows that exist in only one of the two DataFrames.
+
+    Attributes
+    ----------
+    has_rows : bool
+        True when there is at least one unique row to display.
+    rows : str
+        Pre-rendered string table of sample rows.
+    """
+
+    has_rows: bool
+    rows: str = ""
+
+
+@dataclass(frozen=True)
+class ReportData:
+    """Complete data model for a DataComPy comparison report.
+
+    Produced by ``compare.build_report_data()``.  All fields are immutable
+    so the object can be safely cached or passed across threads.
+
+    Attributes
+    ----------
+    df1_name : str
+        Label for the first DataFrame.
+    df2_name : str
+        Label for the second DataFrame.
+    df1_shape : tuple of (int, int)
+        ``(rows, columns)`` of df1.
+    df2_shape : tuple of (int, int)
+        ``(rows, columns)`` of df2.
+    column_count : int
+        Maximum number of columns displayed in unique-row samples.
+    column_summary : ColumnSummary
+    row_summary : RowSummary
+    column_comparison : ColumnComparison
+    mismatch_stats : MismatchStats
+    df1_unique_rows : UniqueRowsData
+    df2_unique_rows : UniqueRowsData
+    """
+
+    df1_name: str
+    df2_name: str
+    df1_shape: Tuple[int, int]
+    df2_shape: Tuple[int, int]
+    column_count: int
+    column_summary: ColumnSummary
+    row_summary: RowSummary
+    column_comparison: ColumnComparison
+    mismatch_stats: MismatchStats
+    df1_unique_rows: UniqueRowsData
+    df2_unique_rows: UniqueRowsData
+
+    def to_template_context(self) -> Dict[str, Any]:
+        """Return a dict suitable for passing directly to the Jinja2 template.
+
+        The template contract uses pre-formatted strings for a few fields
+        (``row_summary.match_columns`` as comma-joined str, ``has_duplicates``
+        as ``"Yes"``/``"No"``).  This method emits those stringified values
+        while keeping the dataclass fields typed.
+
+        Returns
+        -------
+        dict
+            Context dict accepted by ``report_template.j2`` and custom templates.
+        """
+        cs = self.column_summary
+        rs = self.row_summary
+
+        match_columns_str = "index" if rs.on_index else ", ".join(rs.match_columns)
+        has_dupes_str = "Yes" if rs.has_duplicates else "No"
+
+        # Render df1/df2_unique as "N ['col1', 'col2', ...]" when there are
+        # unique columns (consistent across all backends), or plain N when empty.
+        def _fmt_unique(count: int, cols: Tuple[str, ...]) -> Any:
+            return f"{count} {list(cols)}" if cols else count
+
+        return {
+            "df1_name": self.df1_name,
+            "df2_name": self.df2_name,
+            "df1_shape": self.df1_shape,
+            "df2_shape": self.df2_shape,
+            "column_count": self.column_count,
+            "column_summary": {
+                "common_columns": cs.common_columns,
+                "df1_unique": _fmt_unique(cs.df1_unique, cs.df1_unique_columns),
+                "df2_unique": _fmt_unique(cs.df2_unique, cs.df2_unique_columns),
+                "df1_name": cs.df1_name,
+                "df2_name": cs.df2_name,
+            },
+            "row_summary": {
+                "match_columns": match_columns_str,
+                "has_duplicates": has_dupes_str,
+                "abs_tol": rs.abs_tol,
+                "rel_tol": rs.rel_tol,
+                "common_rows": rs.common_rows,
+                "df1_unique": rs.df1_unique,
+                "df2_unique": rs.df2_unique,
+                "unequal_rows": rs.unequal_rows,
+                "equal_rows": rs.equal_rows,
+                "df1_name": rs.df1_name,
+                "df2_name": rs.df2_name,
+            },
+            "column_comparison": dataclasses.asdict(self.column_comparison),
+            "mismatch_stats": {
+                "has_mismatches": self.mismatch_stats.has_mismatches,
+                "has_samples": self.mismatch_stats.has_samples,
+                "stats": [dataclasses.asdict(s) for s in self.mismatch_stats.stats],
+                "samples": list(self.mismatch_stats.samples),
+                "df1_name": self.mismatch_stats.df1_name,
+                "df2_name": self.mismatch_stats.df2_name,
+            },
+            "df1_unique_rows": dataclasses.asdict(self.df1_unique_rows),
+            "df2_unique_rows": dataclasses.asdict(self.df2_unique_rows),
+        }
+
+
+class Report:
+    """Renders a :class:`ReportData` to text or HTML.
+
+    Parameters
+    ----------
+    data : ReportData
+        The structured comparison data produced by ``compare.build_report_data()``.
+    template_path : str, optional
+        Path to a custom Jinja2 template.  When ``None`` the default
+        ``report_template.j2`` is used.
+
+    Examples
+    --------
+    >>> data = compare.build_report_data()
+    >>> rep = Report(data)
+    >>> print(rep)                   # text report
+    >>> rep.save("report.html")      # write HTML
+    >>> d = rep.to_dict()            # JSON-serializable dict
+    """
+
+    def __init__(self, data: ReportData, template_path: str | None = None) -> None:
+        self._data = data
+        self._template_path = template_path or "report_template.j2"
+
+    @property
+    def data(self) -> ReportData:
+        """The underlying :class:`ReportData`."""
+        return self._data
+
+    def render(self) -> str:
+        """Render the report to a text string.
+
+        Returns
+        -------
+        str
+            Formatted report text.
+        """
+        return render(self._template_path, **self._data.to_template_context())
+
+    def to_html(self) -> str:
+        """Return the report wrapped in a minimal HTML page.
+
+        Returns
+        -------
+        str
+            HTML string with the text report inside a ``
`` block.
+        """
+        text = self.render()
+        return (
+            f"DataComPy Report"
+            f"
{text}
" + ) + + def save(self, path: str | Path) -> None: + """Save the report as an HTML file. + + Parameters + ---------- + path : str or Path + Destination file path. Parent directories are created if needed. + """ + save_html_report(self.render(), path) + + def to_dict(self) -> Dict[str, Any]: + """Return the report data as a JSON-serializable dict. + + Returns + ------- + dict + ``dataclasses.asdict(data)`` — safe for ``json.dumps()``. + """ + return dataclasses.asdict(self._data) + + def __str__(self) -> str: + """Return the rendered report as a string.""" + return self.render() + + def __repr__(self) -> str: + """Return a concise developer representation.""" + return ( + f"Report(df1={self._data.df1_name!r}, df2={self._data.df2_name!r}, " + f"shape1={self._data.df1_shape}, shape2={self._data.df2_shape})" + ) From 874816120d485b17bc70dd078e329ed3505145f7 Mon Sep 17 00:00:00 2001 From: Faisal Dosani Date: Wed, 17 Jun 2026 09:54:54 -0400 Subject: [PATCH 08/23] fix: update snapshot files to ensure consistent formatting and remove unnecessary changes --- .pre-commit-config.yaml | 2 ++ tests/snapshots/pandas_duplicates.txt | 2 +- tests/snapshots/pandas_no_mismatches.txt | 2 +- tests/snapshots/pandas_on_index.txt | 2 +- tests/snapshots/pandas_sample_count_zero.txt | 2 +- tests/snapshots/pandas_unique_columns.txt | 2 +- tests/snapshots/pandas_unique_rows.txt | 2 +- tests/snapshots/pandas_with_mismatches.txt | 2 +- tests/snapshots/pandas_with_tolerances.txt | 2 +- tests/snapshots/polars_no_mismatches.txt | 2 +- tests/snapshots/polars_unique_columns.txt | 2 +- tests/snapshots/polars_unique_rows.txt | 2 +- tests/snapshots/polars_with_mismatches.txt | 2 +- 13 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 62c3a79e..19c1a30d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,8 +12,10 @@ repos: rev: v5.0.0 hooks: - id: trailing-whitespace + exclude: ^tests/snapshots/ - id: debug-statements - id: end-of-file-fixer + exclude: ^tests/snapshots/ - repo: https://github.com/tox-dev/pyproject-fmt rev: "v2.5.0" hooks: diff --git a/tests/snapshots/pandas_duplicates.txt b/tests/snapshots/pandas_duplicates.txt index a09394f0..8a5d1168 100644 --- a/tests/snapshots/pandas_duplicates.txt +++ b/tests/snapshots/pandas_duplicates.txt @@ -36,4 +36,4 @@ Column Comparison Number of columns compared with some values unequal: 0 Number of columns compared with all values equal: 2 -Total number of values which compare unequal: 0 +Total number of values which compare unequal: 0 \ No newline at end of file diff --git a/tests/snapshots/pandas_no_mismatches.txt b/tests/snapshots/pandas_no_mismatches.txt index eb2db7e2..0269ec5a 100644 --- a/tests/snapshots/pandas_no_mismatches.txt +++ b/tests/snapshots/pandas_no_mismatches.txt @@ -36,4 +36,4 @@ Column Comparison Number of columns compared with some values unequal: 0 Number of columns compared with all values equal: 3 -Total number of values which compare unequal: 0 +Total number of values which compare unequal: 0 \ No newline at end of file diff --git a/tests/snapshots/pandas_on_index.txt b/tests/snapshots/pandas_on_index.txt index 2c1901d4..c3e596d4 100644 --- a/tests/snapshots/pandas_on_index.txt +++ b/tests/snapshots/pandas_on_index.txt @@ -50,4 +50,4 @@ Sample Rows with Unequal Values ------------------------------- val (df1) val (df2) -0 20 99 +0 20 99 \ No newline at end of file diff --git a/tests/snapshots/pandas_sample_count_zero.txt b/tests/snapshots/pandas_sample_count_zero.txt index d3cdfeb8..1fa23ab8 100644 --- a/tests/snapshots/pandas_sample_count_zero.txt +++ b/tests/snapshots/pandas_sample_count_zero.txt @@ -43,4 +43,4 @@ 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 +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 4f5bf81e..6f69fc13 100644 --- a/tests/snapshots/pandas_unique_columns.txt +++ b/tests/snapshots/pandas_unique_columns.txt @@ -36,4 +36,4 @@ Column Comparison Number of columns compared with some values unequal: 0 Number of columns compared with all values equal: 2 -Total number of values which compare unequal: 0 +Total number of values which compare unequal: 0 \ No newline at end of file diff --git a/tests/snapshots/pandas_unique_rows.txt b/tests/snapshots/pandas_unique_rows.txt index bc2cc502..a489b095 100644 --- a/tests/snapshots/pandas_unique_rows.txt +++ b/tests/snapshots/pandas_unique_rows.txt @@ -49,4 +49,4 @@ Sample Rows Only in df2 (First 10 Columns) ------------------------------------------ id val -0 4 40.0 +0 4 40.0 \ No newline at end of file diff --git a/tests/snapshots/pandas_with_mismatches.txt b/tests/snapshots/pandas_with_mismatches.txt index b717aba4..9e4209d8 100644 --- a/tests/snapshots/pandas_with_mismatches.txt +++ b/tests/snapshots/pandas_with_mismatches.txt @@ -54,4 +54,4 @@ Sample Rows with Unequal Values 0 2 20 99 id score (df1) score (df2) -0 2 2.0 2.5 +0 2 2.0 2.5 \ No newline at end of file diff --git a/tests/snapshots/pandas_with_tolerances.txt b/tests/snapshots/pandas_with_tolerances.txt index 936fc1d7..1fecc505 100644 --- a/tests/snapshots/pandas_with_tolerances.txt +++ b/tests/snapshots/pandas_with_tolerances.txt @@ -36,4 +36,4 @@ Column Comparison Number of columns compared with some values unequal: 0 Number of columns compared with all values equal: 2 -Total number of values which compare unequal: 0 +Total number of values which compare unequal: 0 \ No newline at end of file diff --git a/tests/snapshots/polars_no_mismatches.txt b/tests/snapshots/polars_no_mismatches.txt index 2c25f5ea..4dd7514b 100644 --- a/tests/snapshots/polars_no_mismatches.txt +++ b/tests/snapshots/polars_no_mismatches.txt @@ -36,4 +36,4 @@ Column Comparison Number of columns compared with some values unequal: 0 Number of columns compared with all values equal: 2 -Total number of values which compare unequal: 0 +Total number of values which compare unequal: 0 \ No newline at end of file diff --git a/tests/snapshots/polars_unique_columns.txt b/tests/snapshots/polars_unique_columns.txt index 4f5bf81e..6f69fc13 100644 --- a/tests/snapshots/polars_unique_columns.txt +++ b/tests/snapshots/polars_unique_columns.txt @@ -36,4 +36,4 @@ Column Comparison Number of columns compared with some values unequal: 0 Number of columns compared with all values equal: 2 -Total number of values which compare unequal: 0 +Total number of values which compare unequal: 0 \ No newline at end of file diff --git a/tests/snapshots/polars_unique_rows.txt b/tests/snapshots/polars_unique_rows.txt index 1842b673..7298f571 100644 --- a/tests/snapshots/polars_unique_rows.txt +++ b/tests/snapshots/polars_unique_rows.txt @@ -49,4 +49,4 @@ Sample Rows Only in df2 (First 10 Columns) ------------------------------------------ id val -0 4 40 +0 4 40 \ No newline at end of file diff --git a/tests/snapshots/polars_with_mismatches.txt b/tests/snapshots/polars_with_mismatches.txt index 55011c78..1af99cd6 100644 --- a/tests/snapshots/polars_with_mismatches.txt +++ b/tests/snapshots/polars_with_mismatches.txt @@ -54,4 +54,4 @@ Sample Rows with Unequal Values 0 2 20 99 id score (df1) score (df2) -0 2 2.0 2.5 +0 2 2.0 2.5 \ No newline at end of file From 9d3098b3786553df9f2c49a639d8d52efb6a9281 Mon Sep 17 00:00:00 2001 From: Faisal Dosani Date: Wed, 17 Jun 2026 09:55:04 -0400 Subject: [PATCH 09/23] fix: update mock patches in test_spark to reference correct modules for report rendering and HTML saving --- tests/test_spark.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_spark.py b/tests/test_spark.py index 2723b7d0..eb620361 100644 --- a/tests/test_spark.py +++ b/tests/test_spark.py @@ -1568,8 +1568,8 @@ def test_integer_column_names(spark_session): assert compare.matches() -@mock.patch("datacompy.spark.render") -@mock.patch("datacompy.spark.save_html_report") +@mock.patch("datacompy.report.render") +@mock.patch("datacompy.base.save_html_report") def test_save_html(mock_save_html, mock_render, spark_session): df1 = spark_session.createDataFrame([{"a": 1, "b": 2}, {"a": 1, "b": 2}]) df2 = spark_session.createDataFrame([{"a": 1, "b": 2}, {"a": 1, "b": 2}]) @@ -1976,8 +1976,8 @@ def test_template_context_variables(spark_session): os.unlink(template_path) -@mock.patch("datacompy.spark.save_html_report") -@mock.patch("datacompy.spark.render") +@mock.patch("datacompy.base.save_html_report") +@mock.patch("datacompy.report.render") def test_html_report_generation(mock_render, mock_save_html, spark_session): """Test that HTML reports can be generated and saved to a file.""" df1 = spark_session.createDataFrame([("a", 1), ("b", 2)], ["id", "value"]) From f5079b1298fe08c9eff5a0c2b51fd4ad3eb7711a Mon Sep 17 00:00:00 2001 From: Faisal Dosani Date: Wed, 17 Jun 2026 14:15:51 -0400 Subject: [PATCH 10/23] fix: update mock patches in test_snowflake to reference correct modules for report rendering and HTML saving --- tests/test_snowflake.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_snowflake.py b/tests/test_snowflake.py index b7d6dfa4..b7cc26a7 100644 --- a/tests/test_snowflake.py +++ b/tests/test_snowflake.py @@ -1590,8 +1590,8 @@ def test_generate_id_within_group_single_join(snowflake_session): assert (actual["_TEMP_0"] == expected).all() -@mock.patch("datacompy.snowflake.render") -@mock.patch("datacompy.snowflake.save_html_report") +@mock.patch("datacompy.report.render") +@mock.patch("datacompy.base.save_html_report") def test_save_html(mock_save_html, mock_render, snowflake_session): df1 = snowflake_session.createDataFrame([{"A": 1, "B": 2}, {"A": 1, "B": 2}]) df2 = snowflake_session.createDataFrame([{"A": 1, "B": 2}, {"A": 1, "B": 2}]) @@ -1878,8 +1878,8 @@ def test_template_context_variables(snowflake_session): os.unlink(template_path) -@mock.patch("datacompy.snowflake.save_html_report") -@mock.patch("datacompy.snowflake.render") +@mock.patch("datacompy.base.save_html_report") +@mock.patch("datacompy.report.render") def test_html_report_generation(mock_render, mock_save_html, snowflake_session): """Test that HTML reports can be generated and saved to a file.""" df1 = snowflake_session.createDataFrame([("a", 1), ("b", 2)], ["id", "value"]) From 79c613bdc7184abc1238602681393f895110e0ba Mon Sep 17 00:00:00 2001 From: Faisal Dosani Date: Wed, 17 Jun 2026 15:57:16 -0400 Subject: [PATCH 11/23] refactor: remove Report class and update report generation to use ReportData directly --- datacompy/__init__.py | 2 - datacompy/base.py | 15 +- datacompy/report.py | 83 ++++---- tests/test_report.py | 454 +++++++++++++++++++++--------------------- 4 files changed, 264 insertions(+), 290 deletions(-) diff --git a/datacompy/__init__.py b/datacompy/__init__.py index be667bbc..490ad120 100644 --- a/datacompy/__init__.py +++ b/datacompy/__init__.py @@ -28,7 +28,6 @@ ColumnSummary, MismatchStat, MismatchStats, - Report, ReportData, RowSummary, UniqueRowsData, @@ -42,7 +41,6 @@ "MismatchStats", "PandasCompare", "PolarsCompare", - "Report", "ReportData", "RowSummary", "UniqueRowsData", diff --git a/datacompy/base.py b/datacompy/base.py index 41b902b0..b64a9220 100644 --- a/datacompy/base.py +++ b/datacompy/base.py @@ -25,7 +25,10 @@ from abc import ABC, abstractmethod from collections import Counter from pathlib import Path -from typing import Any, Dict, List +from typing import TYPE_CHECKING, Any, Dict, List + +if TYPE_CHECKING: + from datacompy.report import ReportData from jinja2 import Environment, FileSystemLoader, select_autoescape from ordered_set import OrderedSet @@ -236,7 +239,9 @@ def _column_names(self, df: Any) -> List[str]: # Concrete report-building — shared across all backends # ------------------------------------------------------------------ - def build_report_data(self, sample_count: int = 10, column_count: int = 10) -> Any: + def build_report_data( + self, sample_count: int = 10, column_count: int = 10 + ) -> "ReportData": """Build a typed :class:`~datacompy.report.ReportData` from this comparison. Parameters @@ -415,13 +420,9 @@ def report( See Also -------- build_report_data : Access the structured data without rendering. - datacompy.report.Report : Render / export a :class:`~datacompy.report.ReportData`. """ - from datacompy.report import Report - data = self.build_report_data(sample_count, column_count) - rep = Report(data, template_path=template_path) - text = rep.render() + text = data.render(template_path=template_path) if html_file: save_html_report(text, html_file) return text diff --git a/datacompy/report.py b/datacompy/report.py index bbb2b425..bcb17b9a 100644 --- a/datacompy/report.py +++ b/datacompy/report.py @@ -13,11 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Typed data model and Report class for DataComPy report generation. +"""Typed data model for DataComPy report generation. All backends produce a :class:`ReportData` instance via -``compare.build_report_data()``. :class:`Report` owns rendering to text -and HTML, and exposes ``to_dict()`` for programmatic consumers. +``compare.build_report_data()``. :class:`ReportData` owns rendering to +text and HTML, and exposes ``to_dict()`` for programmatic consumers. Examples -------- @@ -29,16 +29,14 @@ Render to text and save HTML: ->>> from datacompy import Report >>> data = compare.build_report_data() ->>> rep = Report(data) ->>> print(rep.render()) ->>> rep.save("comparison.html") +>>> print(data.render()) +>>> data.save("comparison.html") Export as JSON-serializable dict: >>> import json ->>> json.dumps(rep.to_dict()) +>>> json.dumps(data.to_dict()) """ import dataclasses @@ -223,7 +221,7 @@ class UniqueRowsData: rows: str = "" -@dataclass(frozen=True) +@dataclass(frozen=True, repr=False) class ReportData: """Complete data model for a DataComPy comparison report. @@ -325,69 +323,56 @@ def _fmt_unique(count: int, cols: Tuple[str, ...]) -> Any: "df2_unique_rows": dataclasses.asdict(self.df2_unique_rows), } - -class Report: - """Renders a :class:`ReportData` to text or HTML. - - Parameters - ---------- - data : ReportData - The structured comparison data produced by ``compare.build_report_data()``. - template_path : str, optional - Path to a custom Jinja2 template. When ``None`` the default - ``report_template.j2`` is used. - - Examples - -------- - >>> data = compare.build_report_data() - >>> rep = Report(data) - >>> print(rep) # text report - >>> rep.save("report.html") # write HTML - >>> d = rep.to_dict() # JSON-serializable dict - """ - - def __init__(self, data: ReportData, template_path: str | None = None) -> None: - self._data = data - self._template_path = template_path or "report_template.j2" - - @property - def data(self) -> ReportData: - """The underlying :class:`ReportData`.""" - return self._data - - def render(self) -> str: + def render(self, template_path: str | None = None) -> str: """Render the report to a text string. + Parameters + ---------- + template_path : str, optional + Path to a custom Jinja2 template. When ``None`` the default + ``report_template.j2`` is used. + Returns ------- str Formatted report text. """ - return render(self._template_path, **self._data.to_template_context()) + return render( + template_path or "report_template.j2", **self.to_template_context() + ) - def to_html(self) -> str: + def to_html(self, template_path: str | None = None) -> str: """Return the report wrapped in a minimal HTML page. + Parameters + ---------- + template_path : str, optional + Path to a custom Jinja2 template. When ``None`` the default + ``report_template.j2`` is used. + Returns ------- str HTML string with the text report inside a ``
`` block.
         """
-        text = self.render()
+        text = self.render(template_path)
         return (
             f"DataComPy Report"
             f"
{text}
" ) - def save(self, path: str | Path) -> None: + def save(self, path: str | Path, template_path: str | None = None) -> None: """Save the report as an HTML file. Parameters ---------- path : str or Path Destination file path. Parent directories are created if needed. + template_path : str, optional + Path to a custom Jinja2 template. When ``None`` the default + ``report_template.j2`` is used. """ - save_html_report(self.render(), path) + save_html_report(self.render(template_path), path) def to_dict(self) -> Dict[str, Any]: """Return the report data as a JSON-serializable dict. @@ -395,9 +380,9 @@ def to_dict(self) -> Dict[str, Any]: Returns ------- dict - ``dataclasses.asdict(data)`` — safe for ``json.dumps()``. + ``dataclasses.asdict(self)`` — safe for ``json.dumps()``. """ - return dataclasses.asdict(self._data) + return dataclasses.asdict(self) def __str__(self) -> str: """Return the rendered report as a string.""" @@ -406,6 +391,6 @@ def __str__(self) -> str: def __repr__(self) -> str: """Return a concise developer representation.""" return ( - f"Report(df1={self._data.df1_name!r}, df2={self._data.df2_name!r}, " - f"shape1={self._data.df1_shape}, shape2={self._data.df2_shape})" + f"ReportData(df1={self.df1_name!r}, df2={self.df2_name!r}, " + f"shape1={self.df1_shape}, shape2={self.df2_shape})" ) diff --git a/tests/test_report.py b/tests/test_report.py index 0c924d23..d95b236d 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -14,7 +14,6 @@ MismatchStats, PandasCompare, PolarsCompare, - Report, ReportData, RowSummary, UniqueRowsData, @@ -100,26 +99,28 @@ def _minimal_report_data( # --------------------------------------------------------------------------- -class TestDataclasses: - def test_report_data_is_frozen(self): - data = _minimal_report_data() - with pytest.raises((dataclasses.FrozenInstanceError, AttributeError)): - data.df1_name = "changed" # type: ignore[misc] +def test_report_data_is_frozen(): + data = _minimal_report_data() + with pytest.raises((dataclasses.FrozenInstanceError, AttributeError)): + data.df1_name = "changed" # type: ignore[misc] - def test_mismatch_stat_frozen(self): - stat = MismatchStat("col", "int64", "int64", 1, 1.0, 0, 0.0, 0.0) - with pytest.raises((dataclasses.FrozenInstanceError, AttributeError)): - stat.column = "other" # type: ignore[misc] - def test_mismatch_stats_defaults(self): - ms = MismatchStats(has_mismatches=False, has_samples=False) - assert ms.stats == () - assert ms.samples == () - assert ms.df1_name == "" +def test_mismatch_stat_frozen(): + stat = MismatchStat("col", "int64", "int64", 1, 1.0, 0, 0.0, 0.0) + with pytest.raises((dataclasses.FrozenInstanceError, AttributeError)): + stat.column = "other" # type: ignore[misc] - def test_unique_rows_data_defaults(self): - u = UniqueRowsData(has_rows=False) - assert u.rows == "" + +def test_mismatch_stats_defaults(): + ms = MismatchStats(has_mismatches=False, has_samples=False) + assert ms.stats == () + assert ms.samples == () + assert ms.df1_name == "" + + +def test_unique_rows_data_defaults(): + u = UniqueRowsData(has_rows=False) + assert u.rows == "" # --------------------------------------------------------------------------- @@ -127,141 +128,133 @@ def test_unique_rows_data_defaults(self): # --------------------------------------------------------------------------- -class TestToTemplateContext: - def test_basic_keys_present(self): - ctx = _minimal_report_data().to_template_context() - for key in [ - "df1_name", - "df2_name", - "df1_shape", - "df2_shape", - "column_count", - "column_summary", - "row_summary", - "column_comparison", - "mismatch_stats", - "df1_unique_rows", - "df2_unique_rows", - ]: - assert key in ctx - - def test_row_summary_match_columns_joined(self): - data = _minimal_report_data() - ctx = data.to_template_context() - assert ctx["row_summary"]["match_columns"] == "id" - - def test_row_summary_on_index(self): - data = dataclasses.replace( - _minimal_report_data(), - row_summary=dataclasses.replace( - _minimal_report_data().row_summary, on_index=True - ), - ) - ctx = data.to_template_context() - assert ctx["row_summary"]["match_columns"] == "index" - - def test_row_summary_has_duplicates_string(self): - data = _minimal_report_data() - ctx = data.to_template_context() - assert ctx["row_summary"]["has_duplicates"] in ("Yes", "No") - - def test_df1_unique_formatted_with_cols(self): - data = dataclasses.replace( - _minimal_report_data(), - column_summary=dataclasses.replace( - _minimal_report_data().column_summary, - df1_unique=2, - df1_unique_columns=("col_a", "col_b"), - ), - ) - ctx = data.to_template_context() - assert ctx["column_summary"]["df1_unique"] == "2 ['col_a', 'col_b']" +def test_template_context_basic_keys_present(): + ctx = _minimal_report_data().to_template_context() + for key in [ + "df1_name", + "df2_name", + "df1_shape", + "df2_shape", + "column_count", + "column_summary", + "row_summary", + "column_comparison", + "mismatch_stats", + "df1_unique_rows", + "df2_unique_rows", + ]: + assert key in ctx + + +def test_template_context_row_summary_match_columns_joined(): + ctx = _minimal_report_data().to_template_context() + assert ctx["row_summary"]["match_columns"] == "id" + + +def test_template_context_row_summary_on_index(): + data = dataclasses.replace( + _minimal_report_data(), + row_summary=dataclasses.replace( + _minimal_report_data().row_summary, on_index=True + ), + ) + ctx = data.to_template_context() + assert ctx["row_summary"]["match_columns"] == "index" - def test_df1_unique_plain_int_when_no_unique_cols(self): - ctx = _minimal_report_data().to_template_context() - assert ctx["column_summary"]["df1_unique"] == 0 - def test_mismatch_stats_stats_is_list_of_dicts(self): - data = _minimal_report_data(mismatch=True) - ctx = data.to_template_context() - assert isinstance(ctx["mismatch_stats"]["stats"], list) - assert isinstance(ctx["mismatch_stats"]["stats"][0], dict) - assert "column" in ctx["mismatch_stats"]["stats"][0] +def test_template_context_has_duplicates_string(): + ctx = _minimal_report_data().to_template_context() + assert ctx["row_summary"]["has_duplicates"] in ("Yes", "No") + + +def test_template_context_df1_unique_formatted_with_cols(): + data = dataclasses.replace( + _minimal_report_data(), + column_summary=dataclasses.replace( + _minimal_report_data().column_summary, + df1_unique=2, + df1_unique_columns=("col_a", "col_b"), + ), + ) + ctx = data.to_template_context() + assert ctx["column_summary"]["df1_unique"] == "2 ['col_a', 'col_b']" + + +def test_template_context_df1_unique_plain_int_when_no_unique_cols(): + ctx = _minimal_report_data().to_template_context() + assert ctx["column_summary"]["df1_unique"] == 0 + + +def test_template_context_mismatch_stats_is_list_of_dicts(): + ctx = _minimal_report_data(mismatch=True).to_template_context() + assert isinstance(ctx["mismatch_stats"]["stats"], list) + assert isinstance(ctx["mismatch_stats"]["stats"][0], dict) + assert "column" in ctx["mismatch_stats"]["stats"][0] # --------------------------------------------------------------------------- -# Report class tests +# ReportData rendering / export tests # --------------------------------------------------------------------------- -class TestReport: - def test_render_returns_string(self): - rep = Report(_minimal_report_data()) - text = rep.render() - assert isinstance(text, str) - assert "DataComPy" in text - - def test_str_equals_render(self): - rep = Report(_minimal_report_data()) - assert str(rep) == rep.render() - - def test_repr(self): - rep = Report(_minimal_report_data("left", "right")) - r = repr(rep) - assert "left" in r - assert "right" in r - - def test_to_html_contains_pre(self): - rep = Report(_minimal_report_data()) - html = rep.to_html() - assert "
" in html
-        assert "" in html.lower()
-
-    def test_save_writes_html_file(self, tmp_path):
-        rep = Report(_minimal_report_data())
-        dest = tmp_path / "report.html"
-        rep.save(dest)
-        assert dest.exists()
-        content = dest.read_text()
-        assert "
" in content
-
-    def test_to_dict_json_serializable(self):
-        data = _minimal_report_data(mismatch=True)
-        rep = Report(data)
-        d = rep.to_dict()
-        json_str = json.dumps(d)
-        assert "df1_name" in json_str
-
-    def test_to_dict_roundtrip_field(self):
-        data = _minimal_report_data(mismatch=True)
-        rep = Report(data)
-        d = rep.to_dict()
-        assert d["row_summary"]["common_rows"] == 3
-        assert d["mismatch_stats"]["stats"][0]["column"] == "val"
-
-    def test_data_property(self):
-        data = _minimal_report_data()
-        rep = Report(data)
-        assert rep.data is data
-
-    def test_custom_template(self, tmp_path):
-        tmpl = tmp_path / "custom.j2"
-        tmpl.write_text("{{ df1_name }} vs {{ df2_name }}")
-        rep = Report(_minimal_report_data("left", "right"), template_path=str(tmpl))
-        assert rep.render() == "left vs right"
-
-    def test_report_with_mismatches(self):
-        data = _minimal_report_data(mismatch=True)
-        rep = Report(data)
-        text = rep.render()
-        assert "Columns with Unequal" in text
-        assert "val" in text
-
-    def test_report_no_mismatches(self):
-        data = _minimal_report_data(mismatch=False)
-        rep = Report(data)
-        text = rep.render()
-        assert "Columns with Unequal" not in text
+def test_render_returns_string():
+    text = _minimal_report_data().render()
+    assert isinstance(text, str)
+    assert "DataComPy" in text
+
+
+def test_str_equals_render():
+    data = _minimal_report_data()
+    assert str(data) == data.render()
+
+
+def test_repr():
+    r = repr(_minimal_report_data("left", "right"))
+    assert "left" in r
+    assert "right" in r
+
+
+def test_to_html_contains_pre():
+    html = _minimal_report_data().to_html()
+    assert "
" in html
+    assert "" in html.lower()
+
+
+def test_save_writes_html_file(tmp_path):
+    dest = tmp_path / "report.html"
+    _minimal_report_data().save(dest)
+    assert dest.exists()
+    assert "
" in dest.read_text()
+
+
+def test_to_dict_json_serializable():
+    d = _minimal_report_data(mismatch=True).to_dict()
+    assert "df1_name" in json.dumps(d)
+
+
+def test_to_dict_roundtrip_fields():
+    d = _minimal_report_data(mismatch=True).to_dict()
+    assert d["row_summary"]["common_rows"] == 3
+    assert d["mismatch_stats"]["stats"][0]["column"] == "val"
+
+
+def test_render_with_custom_template(tmp_path):
+    tmpl = tmp_path / "custom.j2"
+    tmpl.write_text("{{ df1_name }} vs {{ df2_name }}")
+    assert (
+        _minimal_report_data("left", "right").render(template_path=str(tmpl))
+        == "left vs right"
+    )
+
+
+def test_render_with_mismatches():
+    text = _minimal_report_data(mismatch=True).render()
+    assert "Columns with Unequal" in text
+    assert "val" in text
+
+
+def test_render_no_mismatches():
+    assert "Columns with Unequal" not in _minimal_report_data(mismatch=False).render()
 
 
 # ---------------------------------------------------------------------------
@@ -269,90 +262,87 @@ def test_report_no_mismatches(self):
 # ---------------------------------------------------------------------------
 
 
-class TestBuildReportData:
-    def test_pandas_returns_report_data(self):
-        df1 = pd.DataFrame({"id": [1, 2, 3], "val": [1, 2, 3]})
-        df2 = pd.DataFrame({"id": [1, 2, 3], "val": [1, 9, 3]})
-        c = PandasCompare(df1, df2, "id")
-        data = c.build_report_data()
-        assert isinstance(data, ReportData)
-        assert data.df1_shape == (3, 2)
-        assert data.row_summary.common_rows == 3
-        assert data.row_summary.unequal_rows == 1
-        assert data.mismatch_stats.has_mismatches is True
-        assert data.mismatch_stats.stats[0].column == "val"
-
-    def test_pandas_mismatch_stats_sorted(self):
-        df1 = pd.DataFrame({"id": [1, 2], "b": [1, 2], "a": [10, 20]})
-        df2 = pd.DataFrame({"id": [1, 2], "b": [9, 9], "a": [99, 99]})
-        c = PandasCompare(df1, df2, "id")
-        data = c.build_report_data()
-        cols = [s.column for s in data.mismatch_stats.stats]
-        assert cols == sorted(cols)
-
-    def test_pandas_no_mismatches(self):
-        df1 = pd.DataFrame({"id": [1, 2], "val": [1, 2]})
-        df2 = pd.DataFrame({"id": [1, 2], "val": [1, 2]})
-        c = PandasCompare(df1, df2, "id")
-        data = c.build_report_data()
-        assert data.mismatch_stats.has_mismatches is False
-        assert data.mismatch_stats.stats == ()
-
-    def test_pandas_unique_columns(self):
-        df1 = pd.DataFrame({"id": [1], "only_in_1": [9]})
-        df2 = pd.DataFrame({"id": [1], "only_in_2": [9]})
-        c = PandasCompare(df1, df2, "id")
-        data = c.build_report_data()
-        assert data.column_summary.df1_unique_columns == ("only_in_1",)
-        assert data.column_summary.df2_unique_columns == ("only_in_2",)
-
-    def test_pandas_unique_rows(self):
-        df1 = pd.DataFrame({"id": [1, 2, 3], "val": [1, 2, 3]})
-        df2 = pd.DataFrame({"id": [1, 2], "val": [1, 2]})
-        c = PandasCompare(df1, df2, "id")
-        data = c.build_report_data()
-        assert data.df1_unique_rows.has_rows is True
-        assert "3" in data.df1_unique_rows.rows
-        assert data.df2_unique_rows.has_rows is False
-
-    def test_pandas_on_index(self):
-        df1 = pd.DataFrame({"val": [1, 2]}, index=[0, 1])
-        df2 = pd.DataFrame({"val": [1, 9]}, index=[0, 1])
-        c = PandasCompare(df1, df2, on_index=True)
-        data = c.build_report_data()
-        assert data.row_summary.on_index is True
-        ctx = data.to_template_context()
-        assert ctx["row_summary"]["match_columns"] == "index"
-
-    def test_pandas_html_file(self, tmp_path):
-        df1 = pd.DataFrame({"id": [1], "val": [1]})
-        df2 = pd.DataFrame({"id": [1], "val": [1]})
-        c = PandasCompare(df1, df2, "id")
-        html_path = str(tmp_path / "report.html")
-        text = c.report(html_file=html_path)
-        assert isinstance(text, str)
-        assert os.path.exists(html_path)
-
-    def test_polars_returns_report_data(self):
-        df1 = pl.DataFrame({"id": [1, 2], "val": [10, 20]})
-        df2 = pl.DataFrame({"id": [1, 2], "val": [10, 99]})
-        c = PolarsCompare(df1, df2, "id")
-        data = c.build_report_data()
-        assert isinstance(data, ReportData)
-        assert data.mismatch_stats.has_mismatches is True
-
-    def test_polars_mismatch_stats_sorted(self):
-        df1 = pl.DataFrame({"id": [1], "z_col": [1], "a_col": [10]})
-        df2 = pl.DataFrame({"id": [1], "z_col": [9], "a_col": [99]})
-        c = PolarsCompare(df1, df2, "id")
-        data = c.build_report_data()
-        cols = [s.column for s in data.mismatch_stats.stats]
-        assert cols == sorted(cols)
-
-    def test_report_method_returns_string(self):
-        df1 = pd.DataFrame({"id": [1, 2], "val": [1, 2]})
-        df2 = pd.DataFrame({"id": [1, 2], "val": [1, 9]})
-        c = PandasCompare(df1, df2, "id")
-        rpt = c.report()
-        assert isinstance(rpt, str)
-        assert "DataComPy" in rpt
+def test_pandas_returns_report_data():
+    df1 = pd.DataFrame({"id": [1, 2, 3], "val": [1, 2, 3]})
+    df2 = pd.DataFrame({"id": [1, 2, 3], "val": [1, 9, 3]})
+    data = PandasCompare(df1, df2, "id").build_report_data()
+    assert isinstance(data, ReportData)
+    assert data.df1_shape == (3, 2)
+    assert data.row_summary.common_rows == 3
+    assert data.row_summary.unequal_rows == 1
+    assert data.mismatch_stats.has_mismatches is True
+    assert data.mismatch_stats.stats[0].column == "val"
+
+
+def test_pandas_mismatch_stats_sorted():
+    df1 = pd.DataFrame({"id": [1, 2], "b": [1, 2], "a": [10, 20]})
+    df2 = pd.DataFrame({"id": [1, 2], "b": [9, 9], "a": [99, 99]})
+    data = PandasCompare(df1, df2, "id").build_report_data()
+    cols = [s.column for s in data.mismatch_stats.stats]
+    assert cols == sorted(cols)
+
+
+def test_pandas_no_mismatches():
+    df1 = pd.DataFrame({"id": [1, 2], "val": [1, 2]})
+    df2 = pd.DataFrame({"id": [1, 2], "val": [1, 2]})
+    data = PandasCompare(df1, df2, "id").build_report_data()
+    assert data.mismatch_stats.has_mismatches is False
+    assert data.mismatch_stats.stats == ()
+
+
+def test_pandas_unique_columns():
+    df1 = pd.DataFrame({"id": [1], "only_in_1": [9]})
+    df2 = pd.DataFrame({"id": [1], "only_in_2": [9]})
+    data = PandasCompare(df1, df2, "id").build_report_data()
+    assert data.column_summary.df1_unique_columns == ("only_in_1",)
+    assert data.column_summary.df2_unique_columns == ("only_in_2",)
+
+
+def test_pandas_unique_rows():
+    df1 = pd.DataFrame({"id": [1, 2, 3], "val": [1, 2, 3]})
+    df2 = pd.DataFrame({"id": [1, 2], "val": [1, 2]})
+    data = PandasCompare(df1, df2, "id").build_report_data()
+    assert data.df1_unique_rows.has_rows is True
+    assert "3" in data.df1_unique_rows.rows
+    assert data.df2_unique_rows.has_rows is False
+
+
+def test_pandas_on_index():
+    df1 = pd.DataFrame({"val": [1, 2]}, index=[0, 1])
+    df2 = pd.DataFrame({"val": [1, 9]}, index=[0, 1])
+    data = PandasCompare(df1, df2, on_index=True).build_report_data()
+    assert data.row_summary.on_index is True
+    assert data.to_template_context()["row_summary"]["match_columns"] == "index"
+
+
+def test_pandas_html_file(tmp_path):
+    df1 = pd.DataFrame({"id": [1], "val": [1]})
+    df2 = pd.DataFrame({"id": [1], "val": [1]})
+    html_path = str(tmp_path / "report.html")
+    text = PandasCompare(df1, df2, "id").report(html_file=html_path)
+    assert isinstance(text, str)
+    assert os.path.exists(html_path)
+
+
+def test_polars_returns_report_data():
+    df1 = pl.DataFrame({"id": [1, 2], "val": [10, 20]})
+    df2 = pl.DataFrame({"id": [1, 2], "val": [10, 99]})
+    data = PolarsCompare(df1, df2, "id").build_report_data()
+    assert isinstance(data, ReportData)
+    assert data.mismatch_stats.has_mismatches is True
+
+
+def test_polars_mismatch_stats_sorted():
+    df1 = pl.DataFrame({"id": [1], "z_col": [1], "a_col": [10]})
+    df2 = pl.DataFrame({"id": [1], "z_col": [9], "a_col": [99]})
+    data = PolarsCompare(df1, df2, "id").build_report_data()
+    cols = [s.column for s in data.mismatch_stats.stats]
+    assert cols == sorted(cols)
+
+
+def test_report_method_returns_string():
+    df1 = pd.DataFrame({"id": [1, 2], "val": [1, 2]})
+    df2 = pd.DataFrame({"id": [1, 2], "val": [1, 9]})
+    rpt = PandasCompare(df1, df2, "id").report()
+    assert isinstance(rpt, str)
+    assert "DataComPy" in rpt

From d197bf133e0b3ca2dee507498ad858c30ccc37fd Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Wed, 17 Jun 2026 16:09:18 -0400
Subject: [PATCH 12/23] docs: update README and report API documentation to
 remove Report class references and use ReportData directly

---
 README.md                  | 11 +++++------
 docs/source/report_api.rst | 35 +++++++++++++++++------------------
 2 files changed, 22 insertions(+), 24 deletions(-)

diff --git a/README.md b/README.md
index 7c397570..e796a5c3 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ the string report:
 
 ```python
 import pandas as pd
-from datacompy import PandasCompare, Report
+from datacompy import PandasCompare
 
 df1 = pd.DataFrame({"id": [1, 2, 3], "val": [10, 20, 30]})
 df2 = pd.DataFrame({"id": [1, 2, 3], "val": [10, 99, 30]})
@@ -76,11 +76,10 @@ data = compare.build_report_data()
 print(data.row_summary.unequal_rows)        # 1
 print(data.mismatch_stats.stats[0].column)  # 'val'
 
-# Render / export
-rep = Report(data)
-print(rep.render())        # same text as compare.report()
-rep.save("report.html")   # HTML file
-rep.to_dict()              # JSON-serializable dict
+# Render / export — methods live on ReportData itself
+print(data.render())      # same text as compare.report()
+data.save("report.html")  # HTML file
+data.to_dict()            # JSON-serializable dict
 ```
 
 See the [Report API documentation](https://capitalone.github.io/datacompy/report_api.html) for the full reference.
diff --git a/docs/source/report_api.rst b/docs/source/report_api.rst
index f097060f..2e08a9f9 100644
--- a/docs/source/report_api.rst
+++ b/docs/source/report_api.rst
@@ -1,10 +1,12 @@
 Report API
 ==========
 
-DataComPy's ``datacompy.report`` module provides a typed data model and
-rendering class that every backend uses to generate comparison reports.
-All backends share the same output structure, making it easy to build
-custom dashboards, export to JSON, or plug in alternative templates.
+DataComPy's ``datacompy.report`` module provides a typed data model where
+every backend produces a :class:`~datacompy.report.ReportData` instance via
+``compare.build_report_data()``.  Rendering methods (``render()``,
+``to_html()``, ``save()``, ``to_dict()``) live directly on ``ReportData``,
+making it easy to build custom dashboards, export to JSON, or plug in
+alternative templates.
 
 Programmatic Access
 -------------------
@@ -35,35 +37,32 @@ The same method is available on all backends (``PolarsCompare``,
 Rendering and Export
 --------------------
 
-Wrap a :class:`~datacompy.report.ReportData` in a
-:class:`~datacompy.report.Report` for rendering:
+Rendering methods live on :class:`~datacompy.report.ReportData` directly:
 
 .. code-block:: python
 
-    from datacompy import Report
-
     data = compare.build_report_data()
-    rep = Report(data)
 
-    # Plain-text report (same as compare.report())
-    print(rep.render())
+    # Plain-text report (same output as compare.report())
+    print(data.render())
 
-    # Save HTML file
-    rep.save("comparison.html")
+    # Save as HTML file
+    data.save("comparison.html")
 
     # JSON-serializable dict (for dashboards / APIs)
     import json
-    payload = json.dumps(rep.to_dict())
+    payload = json.dumps(data.to_dict())
 
 Custom Templates
 ~~~~~~~~~~~~~~~~
 
-Pass a ``template_path`` to use your own Jinja2 template:
+Pass a ``template_path`` to ``render()`` or ``save()`` to use your own Jinja2
+template:
 
 .. code-block:: python
 
-    rep = Report(data, template_path="my_report.j2")
-    print(rep.render())
+    print(data.render(template_path="my_report.j2"))
+    data.save("report.html", template_path="my_report.j2")
 
 The template receives the same context dict as the default
 ``report_template.j2``; see :doc:`template_guide` for the full variable
@@ -74,6 +73,6 @@ Data Classes
 
 .. automodule:: datacompy.report
    :members: ReportData, ColumnSummary, RowSummary, ColumnComparison,
-             MismatchStat, MismatchStats, UniqueRowsData, Report
+             MismatchStat, MismatchStats, UniqueRowsData
    :show-inheritance:
    :undoc-members:

From b050226c7eb112dba0cc714a7db89e687fe42c38 Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Wed, 17 Jun 2026 17:15:59 -0400
Subject: [PATCH 13/23] fix: update references to ReportData in base.py and
 report.py for clarity

---
 datacompy/base.py          | 4 ++--
 datacompy/report.py        | 4 ++--
 docs/source/report_api.rst | 1 +
 3 files changed, 5 insertions(+), 4 deletions(-)

diff --git a/datacompy/base.py b/datacompy/base.py
index b64a9220..2f58af6d 100644
--- a/datacompy/base.py
+++ b/datacompy/base.py
@@ -28,7 +28,7 @@
 from typing import TYPE_CHECKING, Any, Dict, List
 
 if TYPE_CHECKING:
-    from datacompy.report import ReportData
+    import datacompy.report
 
 from jinja2 import Environment, FileSystemLoader, select_autoescape
 from ordered_set import OrderedSet
@@ -241,7 +241,7 @@ def _column_names(self, df: Any) -> List[str]:
 
     def build_report_data(
         self, sample_count: int = 10, column_count: int = 10
-    ) -> "ReportData":
+    ) -> "datacompy.report.ReportData":
         """Build a typed :class:`~datacompy.report.ReportData` from this comparison.
 
         Parameters
diff --git a/datacompy/report.py b/datacompy/report.py
index bcb17b9a..cf1a3586 100644
--- a/datacompy/report.py
+++ b/datacompy/report.py
@@ -15,8 +15,8 @@
 
 """Typed data model for DataComPy report generation.
 
-All backends produce a :class:`ReportData` instance via
-``compare.build_report_data()``.  :class:`ReportData` owns rendering to
+All backends produce a :class:`~datacompy.report.ReportData` instance via
+``compare.build_report_data()``.  :class:`~datacompy.report.ReportData` owns rendering to
 text and HTML, and exposes ``to_dict()`` for programmatic consumers.
 
 Examples
diff --git a/docs/source/report_api.rst b/docs/source/report_api.rst
index 2e08a9f9..32f9f340 100644
--- a/docs/source/report_api.rst
+++ b/docs/source/report_api.rst
@@ -76,3 +76,4 @@ Data Classes
              MismatchStat, MismatchStats, UniqueRowsData
    :show-inheritance:
    :undoc-members:
+   :no-index:

From b3c82f242f59c4c2260608cc3ca41279af81d264 Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Wed, 17 Jun 2026 18:07:49 -0400
Subject: [PATCH 14/23] docs: update template guide to clarify available
 template variables and improve example template

---
 docs/source/template_guide.rst | 127 ++++++++++++++++++---------------
 1 file changed, 68 insertions(+), 59 deletions(-)

diff --git a/docs/source/template_guide.rst b/docs/source/template_guide.rst
index bf3b3957..6f58331d 100644
--- a/docs/source/template_guide.rst
+++ b/docs/source/template_guide.rst
@@ -8,71 +8,79 @@ Template Basics
 ---------------
 
 Custom templates are Jinja2 templates that receive comparison data and format it according to your needs.
-The template receives a context dictionary with various comparison metrics and data samples.
+The template receives a context dictionary produced by ``ReportData.to_template_context()``.
 
 Available Template Variables
 ----------------------------
 
-The following variables are available in the template context:
+The following variables are available in the template context. A few fields
+(``row_summary.match_columns``, ``row_summary.has_duplicates``,
+``column_summary.df1_unique``, ``column_summary.df2_unique``) are
+pre-formatted as display strings rather than raw values so the default
+template can interpolate them directly.
 
    +------------------------+--------------------------------------------------------------------------------+
    | Variable               | Description                                                                    |
    +========================+================================================================================+
+   | ``df1_name``,          | Names of the dataframes being compared.                                        |
+   | ``df2_name`` (str)     |                                                                                |
+   +------------------------+--------------------------------------------------------------------------------+
+   | ``df1_shape``,         | Tuples of ``(rows, columns)``. Index ``[0]`` is row count,                     |
+   | ``df2_shape`` (tuple)  | ``[1]`` is column count.                                                       |
+   +------------------------+--------------------------------------------------------------------------------+
+   | ``column_count``       | Maximum number of columns shown in unique-row sample tables.                   |
+   | (int)                  |                                                                                |
+   +------------------------+--------------------------------------------------------------------------------+
    | ``column_summary``     | Dict with column statistics including:                                         |
-   |                        |                                                                                |
-   |                        | - ``common_columns``: List of columns common to both dataframes                |
-   |                        | - ``df1_unique``: List of columns unique to df1                                |
-   |                        | - ``df2_unique``: List of columns unique to df2                                |
-   |                        | - ``df1_name``: Name of the first dataframe                                    |
-   |                        | - ``df2_name``: Name of the second dataframe                                   |
+   | (dict)                 |                                                                                |
+   |                        | - ``common_columns`` (int): count of columns in both DataFrames                |
+   |                        | - ``df1_unique``: pre-formatted string like                                    |
+   |                        |   ``"3 ['col_a','col_b']"`` when df1 has unique columns; bare                  |
+   |                        |   integer ``0`` when there are none                                            |
+   |                        | - ``df2_unique``: same shape as ``df1_unique`` for df2                         |
+   |                        | - ``df1_name``, ``df2_name`` (str): DataFrame labels                           |
    +------------------------+--------------------------------------------------------------------------------+
    | ``row_summary``        | Dict with row statistics including:                                            |
-   |                        |                                                                                |
-   |                        | - ``match_columns``: List of columns used for matching                         |
-   |                        | - ``abs_tol``: Absolute tolerance between two values                           |
-   |                        | - ``rel_tol``: Relative tolerance between two values                           |
-   |                        | - ``common_rows``: Number of rows in common                                    |
-   |                        | - ``df1_unique``: Number of rows unique to df1                                 |
-   |                        | - ``df2_unique``: Number of rows unique to df2                                 |
-   |                        | - ``unequal_rows``: Number of rows with differences                            |
-   |                        | - ``df1_name``: Name of the first dataframe                                    |
-   |                        | - ``df2_name``: Name of the second dataframe                                   |
+   | (dict)                 |                                                                                |
+   |                        | - ``match_columns`` (str): comma-joined join columns (e.g.                     |
+   |                        |   ``"id, date"``), or ``"index"`` when matching on index                       |
+   |                        | - ``has_duplicates`` (str): ``"Yes"`` or ``"No"``                              |
+   |                        | - ``abs_tol``, ``rel_tol``: float (global) or                                  |
+   |                        |   ``dict[str, float]`` per-column tolerances                                   |
+   |                        | - ``common_rows`` (int): rows present in both DataFrames                       |
+   |                        | - ``df1_unique``, ``df2_unique`` (int): row counts unique to                   |
+   |                        |   each side                                                                    |
+   |                        | - ``unequal_rows`` (int): common rows where at least one                       |
+   |                        |   compared column differs                                                      |
+   |                        | - ``equal_rows`` (int): common rows where all compared columns                 |
+   |                        |   match                                                                        |
+   |                        | - ``df1_name``, ``df2_name`` (str): DataFrame labels                           |
    +------------------------+--------------------------------------------------------------------------------+
    | ``column_comparison``  | Dict with column comparison stats:                                             |
-   |                        |                                                                                |
-   |                        | - ``unequal_columns``: List of columns with mismatches                         |
-   |                        | - ``equal_columns``: List of columns that match exactly                        |
-   |                        | - ``unequal_values``: Count of unequal values across all columns               |
+   | (dict)                 |                                                                                |
+   |                        | - ``unequal_columns`` (int): columns with at least one                         |
+   |                        |   unequal value                                                                |
+   |                        | - ``equal_columns`` (int): columns where all values match                      |
+   |                        | - ``unequal_values`` (int): total individual cell mismatches                   |
    +------------------------+--------------------------------------------------------------------------------+
    | ``mismatch_stats``     | Dict containing:                                                               |
-   |                        |                                                                                |
-   |                        | - ``stats``: List of dicts with per-column mismatch statistics:                |
-   |                        |   - ``column``: Column name                                                    |
-   |                        |   - ``match``: Number of matching values                                       |
-   |                        |   - ``mismatch``: Number of mismatched values                                  |
-   |                        |   - ``null_diff``: Number of null value differences                            |
-   |                        |   - ``total``: Total number of comparisons                                     |
-   |                        | - ``samples``: Sample rows with mismatched values                              |
-   |                        | - ``has_samples``: Boolean indicating if there are any samples                 |
-   |                        | - ``has_mismatches``: Boolean indicating if there are any mismatches           |
-   +------------------------+--------------------------------------------------------------------------------+
-   | ``df1_unique_rows``    | Dict with unique rows in df1:                                                  |
-   |                        |                                                                                |
-   |                        | - ``has_rows``: Boolean indicating if there are unique rows                    |
-   |                        | - ``rows``: Sample of unique rows (as strings)                                 |
-   |                        | - ``columns``: List of column names                                            |
+   | (dict)                 |                                                                                |
+   |                        | - ``has_mismatches`` (bool): true when at least one column                     |
+   |                        |   has unequal values or types                                                  |
+   |                        | - ``has_samples`` (bool): true when sample rows are available                  |
+   |                        | - ``stats`` (list of dict): one entry per mismatched column,                   |
+   |                        |   sorted by column name; keys are ``column``, ``dtype1``,                      |
+   |                        |   ``dtype2``, ``unequal_cnt``, ``max_diff``, ``null_diff``,                    |
+   |                        |   ``rel_tol``, ``abs_tol``                                                     |
+   |                        | - ``samples`` (list of str): pre-rendered ASCII tables of                      |
+   |                        |   sample mismatched rows, one per column with mismatches                       |
+   |                        | - ``df1_name``, ``df2_name`` (str): DataFrame labels                           |
    +------------------------+--------------------------------------------------------------------------------+
-   | ``df2_unique_rows``    | Dict with unique rows in df2:                                                  |
-   |                        |                                                                                |
-   |                        | - ``has_rows``: Boolean indicating if there are unique rows                    |
-   |                        | - ``rows``: Sample of unique rows (as strings)                                 |
-   |                        | - ``columns``: List of column names                                            |
-   +------------------------+--------------------------------------------------------------------------------+
-   | ``df1_shape``,         | Tuples of (rows, columns) for each dataframe                                   |
-   | ``df2_shape``          |                                                                                |
-   +------------------------+--------------------------------------------------------------------------------+
-   | ``df1_name``,          | Names of the dataframes being compared                                         |
-   | ``df2_name``           |                                                                                |
+   | ``df1_unique_rows``,   | Dict with sample rows present in only one DataFrame:                           |
+   | ``df2_unique_rows``    |                                                                                |
+   | (dict)                 | - ``has_rows`` (bool): true when at least one unique row exists                |
+   |                        | - ``rows`` (str): pre-rendered ASCII table; empty string when                  |
+   |                        |   there are no unique rows                                                     |
    +------------------------+--------------------------------------------------------------------------------+
 
 
@@ -93,11 +101,11 @@ Here's a simple example template that shows basic comparison metrics:
 .. code-block:: jinja
 
     # Data Comparison Report
-    =====================
+    =======================
 
     ## DataFrames
-    - {{ df1_name }}: {{ df1_shape[0] }} rows × {{ df1_shape[1] }} columns
-    - {{ df2_name }}: {{ df2_shape[0] }} rows × {{ df2_shape[1] }} columns
+    - {{ df1_name }}: {{ df1_shape[0] }} rows x {{ df1_shape[1] }} columns
+    - {{ df2_name }}: {{ df2_shape[0] }} rows x {{ df2_shape[1] }} columns
 
     ## Column Summary
     - Common columns: {{ column_summary.common_columns }}
@@ -105,14 +113,15 @@ Here's a simple example template that shows basic comparison metrics:
     - Columns only in {{ df2_name }}: {{ column_summary.df2_unique }}
 
     ## Row Summary
-    - Rows with some columns unequal: {{ row_summary.unequal_rows }}
+    - Matched on: {{ row_summary.match_columns }}
+    - Rows in common: {{ row_summary.common_rows }}
     - Rows with all columns equal: {{ row_summary.equal_rows }}
+    - Rows with some columns unequal: {{ row_summary.unequal_rows }}
 
-    {% if mismatch_stats %}
+    {% if mismatch_stats.has_mismatches %}
     ## Mismatched Columns
-    {% for col in mismatch_stats %}
-    - {{ col.column }}: {{ col.unequal_cnt }} mismatches
-      - Match rate: {{ "%.2f"|format(col.match_rate * 100) }}%
+    {% for col in mismatch_stats.stats %}
+    - {{ col.column }}: {{ col.unequal_cnt }} unequal value(s), max diff {{ "%.4f"|format(col.max_diff) }}
     {% endfor %}
     {% endif %}
 
@@ -123,9 +132,9 @@ To use your custom template, pass its path to the ``report()`` method:
 
 .. code-block:: python
 
-    from datacompy.core import Compare
+    from datacompy import PandasCompare
 
-    compare = Compare(df1, df2, join_columns=['id'])
+    compare = PandasCompare(df1, df2, join_columns=['id'])
 
     # Generate report with custom template
     report = compare.report(template_path='path/to/your/template.j2')

From be10c8f241059d1ec23d8c1fb54ee0deececf7f7 Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Wed, 17 Jun 2026 18:09:05 -0400
Subject: [PATCH 15/23] fix: clarify data types in template variable
 descriptions in template guide

---
 docs/source/template_guide.rst | 16 +++++++++-------
 1 file changed, 9 insertions(+), 7 deletions(-)

diff --git a/docs/source/template_guide.rst b/docs/source/template_guide.rst
index 6f58331d..02a15fa7 100644
--- a/docs/source/template_guide.rst
+++ b/docs/source/template_guide.rst
@@ -34,10 +34,11 @@ template can interpolate them directly.
    | ``column_summary``     | Dict with column statistics including:                                         |
    | (dict)                 |                                                                                |
    |                        | - ``common_columns`` (int): count of columns in both DataFrames                |
-   |                        | - ``df1_unique``: pre-formatted string like                                    |
-   |                        |   ``"3 ['col_a','col_b']"`` when df1 has unique columns; bare                  |
-   |                        |   integer ``0`` when there are none                                            |
-   |                        | - ``df2_unique``: same shape as ``df1_unique`` for df2                         |
+   |                        | - ``df1_unique`` (str or int): pre-formatted string like                       |
+   |                        |   ``"3 ['col_a','col_b']"`` when df1 has unique columns;                       |
+   |                        |   bare integer ``0`` when there are none                                       |
+   |                        | - ``df2_unique`` (str or int): same shape as ``df1_unique``                    |
+   |                        |   for df2                                                                      |
    |                        | - ``df1_name``, ``df2_name`` (str): DataFrame labels                           |
    +------------------------+--------------------------------------------------------------------------------+
    | ``row_summary``        | Dict with row statistics including:                                            |
@@ -69,9 +70,10 @@ template can interpolate them directly.
    |                        |   has unequal values or types                                                  |
    |                        | - ``has_samples`` (bool): true when sample rows are available                  |
    |                        | - ``stats`` (list of dict): one entry per mismatched column,                   |
-   |                        |   sorted by column name; keys are ``column``, ``dtype1``,                      |
-   |                        |   ``dtype2``, ``unequal_cnt``, ``max_diff``, ``null_diff``,                    |
-   |                        |   ``rel_tol``, ``abs_tol``                                                     |
+   |                        |   sorted by column name. Each dict has ``column`` (str),                       |
+   |                        |   ``dtype1`` (str), ``dtype2`` (str), ``unequal_cnt`` (int),                   |
+   |                        |   ``max_diff`` (float), ``null_diff`` (int),                                   |
+   |                        |   ``rel_tol`` (float), ``abs_tol`` (float)                                     |
    |                        | - ``samples`` (list of str): pre-rendered ASCII tables of                      |
    |                        |   sample mismatched rows, one per column with mismatches                       |
    |                        | - ``df1_name``, ``df2_name`` (str): DataFrame labels                           |

From 1e49361c29ac7c427648996ce0f19dc04e63b2d2 Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Fri, 19 Jun 2026 10:46:27 -0400
Subject: [PATCH 16/23] fix: update condition in _unique_rows_data to use
 min_sample for clarity

---
 datacompy/base.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/datacompy/base.py b/datacompy/base.py
index 2f58af6d..090a7ea7 100644
--- a/datacompy/base.py
+++ b/datacompy/base.py
@@ -360,7 +360,7 @@ def build_report_data(
         def _unique_rows_data(df: Any, unq_count: int) -> "UniqueRowsData":
             min_sample = min(sample_count, unq_count)
             min_cols = min(column_count, len(self._column_names(df)))
-            if unq_count > 0:
+            if min_sample > 0:
                 rows_str = df_to_str(
                     self._select_first_n_columns(df, min_cols),
                     sample_count=min_sample,

From a752aad9823819754adbcc936904dba25b4753b6 Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Fri, 19 Jun 2026 11:10:45 -0400
Subject: [PATCH 17/23] fix: introduce ColumnStat TypedDict for column
 comparison statistics

---
 datacompy/base.py      | 24 ++++++++++++++++++++----
 datacompy/pandas.py    |  3 ++-
 datacompy/polars.py    | 19 +++++++++++--------
 datacompy/snowflake.py |  3 ++-
 datacompy/spark.py     |  3 ++-
 5 files changed, 37 insertions(+), 15 deletions(-)

diff --git a/datacompy/base.py b/datacompy/base.py
index 090a7ea7..dbf46b15 100644
--- a/datacompy/base.py
+++ b/datacompy/base.py
@@ -25,17 +25,33 @@
 from abc import ABC, abstractmethod
 from collections import Counter
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, Dict, List
-
-if TYPE_CHECKING:
-    import datacompy.report
+from typing import TYPE_CHECKING, Any, Dict, List, TypedDict
 
 from jinja2 import Environment, FileSystemLoader, select_autoescape
 from ordered_set import OrderedSet
 
+if TYPE_CHECKING:
+    import datacompy.report
+
 LOG = logging.getLogger(__name__)
 
 
+class ColumnStat(TypedDict):
+    """Typed contract for per-column comparison statistics populated by each backend."""
+
+    column: str
+    match_column: str
+    match_cnt: int
+    unequal_cnt: int
+    dtype1: str
+    dtype2: str
+    all_match: bool
+    max_diff: float
+    null_diff: int
+    rel_tol: float
+    abs_tol: float
+
+
 class BaseCompare(ABC):
     """Base comparison class."""
 
diff --git a/datacompy/pandas.py b/datacompy/pandas.py
index 0e64d3e9..ae26b74f 100644
--- a/datacompy/pandas.py
+++ b/datacompy/pandas.py
@@ -29,6 +29,7 @@
 
 from datacompy.base import (
     BaseCompare,
+    ColumnStat,
     get_column_tolerance,
     temp_column_name,
     validate_tolerance_parameter,
@@ -158,7 +159,7 @@ def __init__(
         self.df1_unq_rows: pd.DataFrame
         self.df2_unq_rows: pd.DataFrame
         self.intersect_rows: pd.DataFrame
-        self.column_stats: List[Dict[str, Any]] = []
+        self.column_stats: List[ColumnStat] = []
         self._compare(ignore_spaces=ignore_spaces, ignore_case=ignore_case)
 
     def _get_comparators(self) -> List[BaseComparator]:
diff --git a/datacompy/polars.py b/datacompy/polars.py
index dae47a7f..65505e21 100644
--- a/datacompy/polars.py
+++ b/datacompy/polars.py
@@ -30,6 +30,7 @@
 
 from datacompy.base import (
     BaseCompare,
+    ColumnStat,
     get_column_tolerance,
     temp_column_name,
     validate_tolerance_parameter,
@@ -146,7 +147,7 @@ def __init__(
         self.df1_unq_rows: pl.DataFrame
         self.df2_unq_rows: pl.DataFrame
         self.intersect_rows: pl.DataFrame
-        self.column_stats: List[Dict[str, Any]] = []
+        self.column_stats: List[ColumnStat] = []
         self._compare(ignore_spaces=ignore_spaces, ignore_case=ignore_case)
 
     def _get_comparators(self) -> List[BaseComparator]:
@@ -416,8 +417,8 @@ def _intersect_compare(self, ignore_spaces: bool, ignore_case: bool) -> None:
         creates a column column_match which is True for matches, False
         otherwise.
         """
-        match_cnt: int | float
-        null_diff: int | float
+        match_cnt: int
+        null_diff: int
 
         LOG.debug("Comparing intersection")
         for column in self.intersect_columns():
@@ -450,14 +451,16 @@ def _intersect_compare(self, ignore_spaces: bool, ignore_case: bool) -> None:
                         comparators=self._get_comparators(),
                     ).alias(col_match)
                 )
-                match_cnt = self.intersect_rows[col_match].sum()
+                match_cnt = int(self.intersect_rows[col_match].sum())
                 max_diff = calculate_max_diff(
                     self.intersect_rows[col_1], self.intersect_rows[col_2]
                 )
-                null_diff = (
-                    (self.intersect_rows[col_1].is_null())
-                    ^ (self.intersect_rows[col_2].is_null())
-                ).sum()
+                null_diff = int(
+                    (
+                        (self.intersect_rows[col_1].is_null())
+                        ^ (self.intersect_rows[col_2].is_null())
+                    ).sum()
+                )
             if row_cnt > 0:
                 match_rate = float(match_cnt) / row_cnt
             else:
diff --git a/datacompy/snowflake.py b/datacompy/snowflake.py
index 8dd9015b..4b4b91d2 100644
--- a/datacompy/snowflake.py
+++ b/datacompy/snowflake.py
@@ -58,6 +58,7 @@
 
 from datacompy.base import (
     BaseCompare,
+    ColumnStat,
     get_column_tolerance,
     validate_tolerance_parameter,
 )
@@ -177,7 +178,7 @@ def __init__(
         self.df1_unq_rows: sp.DataFrame
         self.df2_unq_rows: sp.DataFrame
         self.intersect_rows: sp.DataFrame
-        self.column_stats: List[Dict[str, Any]] = []
+        self.column_stats: List[ColumnStat] = []
         self._compare(ignore_spaces=ignore_spaces, ignore_case=ignore_case)
 
     def _get_comparators(self) -> List[BaseComparator]:
diff --git a/datacompy/spark.py b/datacompy/spark.py
index f3cdc5ad..ee721b7c 100644
--- a/datacompy/spark.py
+++ b/datacompy/spark.py
@@ -34,6 +34,7 @@
 
 from datacompy.base import (
     BaseCompare,
+    ColumnStat,
     get_column_tolerance,
     temp_column_name,
     validate_tolerance_parameter,
@@ -182,7 +183,7 @@ def __init__(
         self.df1_unq_rows: pyspark.sql.DataFrame
         self.df2_unq_rows: pyspark.sql.DataFrame
         self.intersect_rows: pyspark.sql.DataFrame
-        self.column_stats: List = []
+        self.column_stats: List[ColumnStat] = []
         self._compare(ignore_spaces=ignore_spaces, ignore_case=ignore_case)
 
     def _get_comparators(self) -> List[BaseComparator]:

From 8effb86be4672ad72de4b8419f8f8fe7c1ff42a7 Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Fri, 19 Jun 2026 11:26:37 -0400
Subject: [PATCH 18/23] refactor: streamline template rendering by removing
 to_template_context method

---
 datacompy/report.py                    | 67 +----------------------
 datacompy/templates/report_template.j2 |  8 +--
 tests/test_report.py                   | 75 +++++++++++++-------------
 3 files changed, 41 insertions(+), 109 deletions(-)

diff --git a/datacompy/report.py b/datacompy/report.py
index cf1a3586..093110f7 100644
--- a/datacompy/report.py
+++ b/datacompy/report.py
@@ -260,69 +260,6 @@ class ReportData:
     df1_unique_rows: UniqueRowsData
     df2_unique_rows: UniqueRowsData
 
-    def to_template_context(self) -> Dict[str, Any]:
-        """Return a dict suitable for passing directly to the Jinja2 template.
-
-        The template contract uses pre-formatted strings for a few fields
-        (``row_summary.match_columns`` as comma-joined str, ``has_duplicates``
-        as ``"Yes"``/``"No"``).  This method emits those stringified values
-        while keeping the dataclass fields typed.
-
-        Returns
-        -------
-        dict
-            Context dict accepted by ``report_template.j2`` and custom templates.
-        """
-        cs = self.column_summary
-        rs = self.row_summary
-
-        match_columns_str = "index" if rs.on_index else ", ".join(rs.match_columns)
-        has_dupes_str = "Yes" if rs.has_duplicates else "No"
-
-        # Render df1/df2_unique as "N ['col1', 'col2', ...]" when there are
-        # unique columns (consistent across all backends), or plain N when empty.
-        def _fmt_unique(count: int, cols: Tuple[str, ...]) -> Any:
-            return f"{count} {list(cols)}" if cols else count
-
-        return {
-            "df1_name": self.df1_name,
-            "df2_name": self.df2_name,
-            "df1_shape": self.df1_shape,
-            "df2_shape": self.df2_shape,
-            "column_count": self.column_count,
-            "column_summary": {
-                "common_columns": cs.common_columns,
-                "df1_unique": _fmt_unique(cs.df1_unique, cs.df1_unique_columns),
-                "df2_unique": _fmt_unique(cs.df2_unique, cs.df2_unique_columns),
-                "df1_name": cs.df1_name,
-                "df2_name": cs.df2_name,
-            },
-            "row_summary": {
-                "match_columns": match_columns_str,
-                "has_duplicates": has_dupes_str,
-                "abs_tol": rs.abs_tol,
-                "rel_tol": rs.rel_tol,
-                "common_rows": rs.common_rows,
-                "df1_unique": rs.df1_unique,
-                "df2_unique": rs.df2_unique,
-                "unequal_rows": rs.unequal_rows,
-                "equal_rows": rs.equal_rows,
-                "df1_name": rs.df1_name,
-                "df2_name": rs.df2_name,
-            },
-            "column_comparison": dataclasses.asdict(self.column_comparison),
-            "mismatch_stats": {
-                "has_mismatches": self.mismatch_stats.has_mismatches,
-                "has_samples": self.mismatch_stats.has_samples,
-                "stats": [dataclasses.asdict(s) for s in self.mismatch_stats.stats],
-                "samples": list(self.mismatch_stats.samples),
-                "df1_name": self.mismatch_stats.df1_name,
-                "df2_name": self.mismatch_stats.df2_name,
-            },
-            "df1_unique_rows": dataclasses.asdict(self.df1_unique_rows),
-            "df2_unique_rows": dataclasses.asdict(self.df2_unique_rows),
-        }
-
     def render(self, template_path: str | None = None) -> str:
         """Render the report to a text string.
 
@@ -337,9 +274,7 @@ def render(self, template_path: str | None = None) -> str:
         str
             Formatted report text.
         """
-        return render(
-            template_path or "report_template.j2", **self.to_template_context()
-        )
+        return render(template_path or "report_template.j2", **dataclasses.asdict(self))
 
     def to_html(self, template_path: str | None = None) -> str:
         """Return the report wrapped in a minimal HTML page.
diff --git a/datacompy/templates/report_template.j2 b/datacompy/templates/report_template.j2
index f0020981..7eb03e2c 100644
--- a/datacompy/templates/report_template.j2
+++ b/datacompy/templates/report_template.j2
@@ -14,14 +14,14 @@ Column Summary
 --------------
 
 Number of columns in common: {{ column_summary.common_columns }}
-Number of columns in {{ column_summary.df1_name }} but not in {{ column_summary.df2_name }}: {{ column_summary.df1_unique }}
-Number of columns in {{ column_summary.df2_name }} but not in {{ column_summary.df1_name }}: {{ column_summary.df2_unique }}
+Number of columns in {{ column_summary.df1_name }} but not in {{ column_summary.df2_name }}: {{ column_summary.df1_unique ~ " " ~ column_summary.df1_unique_columns | list if column_summary.df1_unique_columns else column_summary.df1_unique }}
+Number of columns in {{ column_summary.df2_name }} but not in {{ column_summary.df1_name }}: {{ column_summary.df2_unique ~ " " ~ column_summary.df2_unique_columns | list if column_summary.df2_unique_columns else column_summary.df2_unique }}
 
 Row Summary
 -----------
 
-Matched on: {{ row_summary.match_columns }}
-Any duplicates on match values: {{ row_summary.has_duplicates }}
+Matched on: {{ "index" if row_summary.on_index else row_summary.match_columns | join(", ") }}
+Any duplicates on match values: {{ "Yes" if row_summary.has_duplicates else "No" }}
 Default Absolute Tolerance: {{ row_summary.abs_tol }}
 Default Relative Tolerance: {{ row_summary.rel_tol }}
 Number of rows in common: {{ "{:,}".format(row_summary.common_rows) }}
diff --git a/tests/test_report.py b/tests/test_report.py
index d95b236d..342bfd1e 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -124,50 +124,52 @@ def test_unique_rows_data_defaults():
 
 
 # ---------------------------------------------------------------------------
-# to_template_context tests
+# Template rendering formatting tests
+# (formatting previously done in to_template_context, now done in the template)
 # ---------------------------------------------------------------------------
 
 
-def test_template_context_basic_keys_present():
-    ctx = _minimal_report_data().to_template_context()
-    for key in [
-        "df1_name",
-        "df2_name",
-        "df1_shape",
-        "df2_shape",
-        "column_count",
-        "column_summary",
-        "row_summary",
-        "column_comparison",
-        "mismatch_stats",
-        "df1_unique_rows",
-        "df2_unique_rows",
-    ]:
-        assert key in ctx
+def test_render_match_columns_joined():
+    assert "Matched on: id" in _minimal_report_data().render()
 
 
-def test_template_context_row_summary_match_columns_joined():
-    ctx = _minimal_report_data().to_template_context()
-    assert ctx["row_summary"]["match_columns"] == "id"
+def test_render_match_columns_on_index():
+    data = dataclasses.replace(
+        _minimal_report_data(),
+        row_summary=dataclasses.replace(
+            _minimal_report_data().row_summary, on_index=True
+        ),
+    )
+    assert "Matched on: index" in data.render()
 
 
-def test_template_context_row_summary_on_index():
+def test_render_match_columns_multi_column():
     data = dataclasses.replace(
         _minimal_report_data(),
         row_summary=dataclasses.replace(
-            _minimal_report_data().row_summary, on_index=True
+            _minimal_report_data().row_summary,
+            match_columns=("id", "name"),
+            on_index=False,
+        ),
+    )
+    assert "Matched on: id, name" in data.render()
+
+
+def test_render_has_duplicates_yes():
+    data = dataclasses.replace(
+        _minimal_report_data(),
+        row_summary=dataclasses.replace(
+            _minimal_report_data().row_summary, has_duplicates=True
         ),
     )
-    ctx = data.to_template_context()
-    assert ctx["row_summary"]["match_columns"] == "index"
+    assert "Any duplicates on match values: Yes" in data.render()
 
 
-def test_template_context_has_duplicates_string():
-    ctx = _minimal_report_data().to_template_context()
-    assert ctx["row_summary"]["has_duplicates"] in ("Yes", "No")
+def test_render_has_duplicates_no():
+    assert "Any duplicates on match values: No" in _minimal_report_data().render()
 
 
-def test_template_context_df1_unique_formatted_with_cols():
+def test_render_unique_columns_formatted_with_names():
     data = dataclasses.replace(
         _minimal_report_data(),
         column_summary=dataclasses.replace(
@@ -176,20 +178,15 @@ def test_template_context_df1_unique_formatted_with_cols():
             df1_unique_columns=("col_a", "col_b"),
         ),
     )
-    ctx = data.to_template_context()
-    assert ctx["column_summary"]["df1_unique"] == "2 ['col_a', 'col_b']"
+    assert "2 ['col_a', 'col_b']" in data.render()
 
 
-def test_template_context_df1_unique_plain_int_when_no_unique_cols():
-    ctx = _minimal_report_data().to_template_context()
-    assert ctx["column_summary"]["df1_unique"] == 0
+def test_render_unique_columns_plain_zero_when_none():
+    assert "but not in df2: 0\n" in _minimal_report_data().render()
 
 
-def test_template_context_mismatch_stats_is_list_of_dicts():
-    ctx = _minimal_report_data(mismatch=True).to_template_context()
-    assert isinstance(ctx["mismatch_stats"]["stats"], list)
-    assert isinstance(ctx["mismatch_stats"]["stats"][0], dict)
-    assert "column" in ctx["mismatch_stats"]["stats"][0]
+def test_render_mismatch_stats_column_name_present():
+    assert "val" in _minimal_report_data(mismatch=True).render()
 
 
 # ---------------------------------------------------------------------------
@@ -312,7 +309,7 @@ def test_pandas_on_index():
     df2 = pd.DataFrame({"val": [1, 9]}, index=[0, 1])
     data = PandasCompare(df1, df2, on_index=True).build_report_data()
     assert data.row_summary.on_index is True
-    assert data.to_template_context()["row_summary"]["match_columns"] == "index"
+    assert "Matched on: index" in data.render()
 
 
 def test_pandas_html_file(tmp_path):

From 39f7eb6e960ce762e625cd05edffe23674d08ffa Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Fri, 19 Jun 2026 11:28:35 -0400
Subject: [PATCH 19/23] fix: clarify template context description in
 documentation

---
 docs/source/report_api.rst     |  2 +-
 docs/source/template_guide.rst | 70 +++++++++++++++++++++-------------
 2 files changed, 44 insertions(+), 28 deletions(-)

diff --git a/docs/source/report_api.rst b/docs/source/report_api.rst
index 32f9f340..8d66f203 100644
--- a/docs/source/report_api.rst
+++ b/docs/source/report_api.rst
@@ -64,7 +64,7 @@ template:
     print(data.render(template_path="my_report.j2"))
     data.save("report.html", template_path="my_report.j2")
 
-The template receives the same context dict as the default
+The template receives the same context as the default
 ``report_template.j2``; see :doc:`template_guide` for the full variable
 reference.
 
diff --git a/docs/source/template_guide.rst b/docs/source/template_guide.rst
index 02a15fa7..44388685 100644
--- a/docs/source/template_guide.rst
+++ b/docs/source/template_guide.rst
@@ -8,44 +8,43 @@ Template Basics
 ---------------
 
 Custom templates are Jinja2 templates that receive comparison data and format it according to your needs.
-The template receives a context dictionary produced by ``ReportData.to_template_context()``.
+The template context is produced by calling ``dataclasses.asdict()`` on the
+:class:`~datacompy.report.ReportData` instance, so every field is passed in
+with its **typed value** — no pre-formatting is applied.  All formatting
+decisions belong in the template.
 
 Available Template Variables
 ----------------------------
 
-The following variables are available in the template context. A few fields
-(``row_summary.match_columns``, ``row_summary.has_duplicates``,
-``column_summary.df1_unique``, ``column_summary.df2_unique``) are
-pre-formatted as display strings rather than raw values so the default
-template can interpolate them directly.
+The following variables are available in the template context.  They mirror
+the fields of :class:`~datacompy.report.ReportData` and its nested dataclasses.
 
    +------------------------+--------------------------------------------------------------------------------+
    | Variable               | Description                                                                    |
    +========================+================================================================================+
-   | ``df1_name``,          | Names of the dataframes being compared.                                        |
-   | ``df2_name`` (str)     |                                                                                |
+   | ``df1_name``,          | Names of the dataframes being compared (str).                                  |
+   | ``df2_name``           |                                                                                |
    +------------------------+--------------------------------------------------------------------------------+
    | ``df1_shape``,         | Tuples of ``(rows, columns)``. Index ``[0]`` is row count,                     |
-   | ``df2_shape`` (tuple)  | ``[1]`` is column count.                                                       |
+   | ``df2_shape``          | ``[1]`` is column count.                                                       |
    +------------------------+--------------------------------------------------------------------------------+
-   | ``column_count``       | Maximum number of columns shown in unique-row sample tables.                   |
-   | (int)                  |                                                                                |
+   | ``column_count``       | Maximum number of columns shown in unique-row sample tables (int).             |
    +------------------------+--------------------------------------------------------------------------------+
    | ``column_summary``     | Dict with column statistics including:                                         |
-   | (dict)                 |                                                                                |
+   |                        |                                                                                |
    |                        | - ``common_columns`` (int): count of columns in both DataFrames                |
-   |                        | - ``df1_unique`` (str or int): pre-formatted string like                       |
-   |                        |   ``"3 ['col_a','col_b']"`` when df1 has unique columns;                       |
-   |                        |   bare integer ``0`` when there are none                                       |
-   |                        | - ``df2_unique`` (str or int): same shape as ``df1_unique``                    |
-   |                        |   for df2                                                                      |
+   |                        | - ``df1_unique`` (int): number of columns only in df1                          |
+   |                        | - ``df1_unique_columns`` (list of str): names of those columns                 |
+   |                        | - ``df2_unique`` (int): number of columns only in df2                          |
+   |                        | - ``df2_unique_columns`` (list of str): names of those columns                 |
    |                        | - ``df1_name``, ``df2_name`` (str): DataFrame labels                           |
    +------------------------+--------------------------------------------------------------------------------+
    | ``row_summary``        | Dict with row statistics including:                                            |
-   | (dict)                 |                                                                                |
-   |                        | - ``match_columns`` (str): comma-joined join columns (e.g.                     |
-   |                        |   ``"id, date"``), or ``"index"`` when matching on index                       |
-   |                        | - ``has_duplicates`` (str): ``"Yes"`` or ``"No"``                              |
+   |                        |                                                                                |
+   |                        | - ``match_columns`` (list of str): join column names; empty                    |
+   |                        |   list when matching on index                                                  |
+   |                        | - ``on_index`` (bool): true when the comparison was joined on index            |
+   |                        | - ``has_duplicates`` (bool): true when duplicate join-key values exist         |
    |                        | - ``abs_tol``, ``rel_tol``: float (global) or                                  |
    |                        |   ``dict[str, float]`` per-column tolerances                                   |
    |                        | - ``common_rows`` (int): rows present in both DataFrames                       |
@@ -58,14 +57,14 @@ template can interpolate them directly.
    |                        | - ``df1_name``, ``df2_name`` (str): DataFrame labels                           |
    +------------------------+--------------------------------------------------------------------------------+
    | ``column_comparison``  | Dict with column comparison stats:                                             |
-   | (dict)                 |                                                                                |
+   |                        |                                                                                |
    |                        | - ``unequal_columns`` (int): columns with at least one                         |
    |                        |   unequal value                                                                |
    |                        | - ``equal_columns`` (int): columns where all values match                      |
    |                        | - ``unequal_values`` (int): total individual cell mismatches                   |
    +------------------------+--------------------------------------------------------------------------------+
    | ``mismatch_stats``     | Dict containing:                                                               |
-   | (dict)                 |                                                                                |
+   |                        |                                                                                |
    |                        | - ``has_mismatches`` (bool): true when at least one column                     |
    |                        |   has unequal values or types                                                  |
    |                        | - ``has_samples`` (bool): true when sample rows are available                  |
@@ -80,11 +79,28 @@ template can interpolate them directly.
    +------------------------+--------------------------------------------------------------------------------+
    | ``df1_unique_rows``,   | Dict with sample rows present in only one DataFrame:                           |
    | ``df2_unique_rows``    |                                                                                |
-   | (dict)                 | - ``has_rows`` (bool): true when at least one unique row exists                |
+   |                        | - ``has_rows`` (bool): true when at least one unique row exists                |
    |                        | - ``rows`` (str): pre-rendered ASCII table; empty string when                  |
    |                        |   there are no unique rows                                                     |
    +------------------------+--------------------------------------------------------------------------------+
 
+Formatting typed values in templates
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Because field values are typed (not pre-formatted), use Jinja2 expressions
+to produce display strings where needed.  Three common patterns:
+
+.. code-block:: jinja
+
+    {# match_columns is a list; on_index is a bool #}
+    Matched on: {{ "index" if row_summary.on_index else row_summary.match_columns | join(", ") }}
+
+    {# has_duplicates is a bool #}
+    Duplicates: {{ "Yes" if row_summary.has_duplicates else "No" }}
+
+    {# df1_unique is an int; df1_unique_columns is a list #}
+    Unique to df1: {{ column_summary.df1_unique ~ " " ~ column_summary.df1_unique_columns if column_summary.df1_unique_columns else column_summary.df1_unique }}
+
 
 Creating a Custom Template
 --------------------------
@@ -111,11 +127,11 @@ Here's a simple example template that shows basic comparison metrics:
 
     ## Column Summary
     - Common columns: {{ column_summary.common_columns }}
-    - Columns only in {{ df1_name }}: {{ column_summary.df1_unique }}
-    - Columns only in {{ df2_name }}: {{ column_summary.df2_unique }}
+    - Columns only in {{ df1_name }}: {{ column_summary.df1_unique ~ " " ~ column_summary.df1_unique_columns if column_summary.df1_unique_columns else column_summary.df1_unique }}
+    - Columns only in {{ df2_name }}: {{ column_summary.df2_unique ~ " " ~ column_summary.df2_unique_columns if column_summary.df2_unique_columns else column_summary.df2_unique }}
 
     ## Row Summary
-    - Matched on: {{ row_summary.match_columns }}
+    - Matched on: {{ "index" if row_summary.on_index else row_summary.match_columns | join(", ") }}
     - Rows in common: {{ row_summary.common_rows }}
     - Rows with all columns equal: {{ row_summary.equal_rows }}
     - Rows with some columns unequal: {{ row_summary.unequal_rows }}

From 0e47884fbb435c8d7870481d87e26dbd18ffeadb Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Fri, 19 Jun 2026 11:41:50 -0400
Subject: [PATCH 20/23] fix: update df_to_str to return string representation
 of DataFrame directly

---
 datacompy/base.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/datacompy/base.py b/datacompy/base.py
index dbf46b15..eeaad0af 100644
--- a/datacompy/base.py
+++ b/datacompy/base.py
@@ -652,7 +652,7 @@ def df_to_str(df: Any, sample_count: int | None = None, on_index: bool = False)
     if hasattr(df, "to_pandas"):
         if sample_count is not None and len(df) > sample_count:
             df = df.head(sample_count)
-        return df.to_pandas().to_string()
+        return str(df)
 
     # Fallback to str() if we can't determine the type
     return str(df)

From f0453b913735bdb4f4269dccee7221b1d42acbe0 Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Fri, 19 Jun 2026 11:55:50 -0400
Subject: [PATCH 21/23] fix: update dataframe property docstrings for clarity

---
 datacompy/base.py      | 6 +++---
 datacompy/pandas.py    | 4 ++--
 datacompy/polars.py    | 4 ++--
 datacompy/snowflake.py | 4 ++--
 datacompy/spark.py     | 4 ++--
 5 files changed, 11 insertions(+), 11 deletions(-)

diff --git a/datacompy/base.py b/datacompy/base.py
index eeaad0af..94442b9d 100644
--- a/datacompy/base.py
+++ b/datacompy/base.py
@@ -57,7 +57,7 @@ class BaseCompare(ABC):
 
     @property
     def df1(self) -> Any:
-        """Get the first dataframe."""
+        """The first dataframe."""
         return self._df1  # type: ignore
 
     @df1.setter
@@ -68,7 +68,7 @@ def df1(self, df1: Any) -> None:
 
     @property
     def df2(self) -> Any:
-        """Get the second dataframe."""
+        """The second dataframe."""
         return self._df2  # type: ignore
 
     @df2.setter
@@ -79,7 +79,7 @@ def df2(self, df2: Any) -> None:
 
     @property
     def sensitive_columns(self) -> List[str] | None:
-        """Get the list of sensitive columns."""
+        """Sensitive columns to mask in report output."""
         return self._sensitive_columns
 
     def _set_and_validate_sensitive_columns(
diff --git a/datacompy/pandas.py b/datacompy/pandas.py
index ae26b74f..029f68fd 100644
--- a/datacompy/pandas.py
+++ b/datacompy/pandas.py
@@ -171,7 +171,7 @@ def _get_comparators(self) -> List[BaseComparator]:
 
     @property
     def df1(self) -> pd.DataFrame:
-        """Get the first dataframe."""
+        """The first dataframe."""
         return self._df1
 
     @df1.setter
@@ -184,7 +184,7 @@ def df1(self, df1: pd.DataFrame) -> None:
 
     @property
     def df2(self) -> pd.DataFrame:
-        """Get the second dataframe."""
+        """The second dataframe."""
         return self._df2
 
     @df2.setter
diff --git a/datacompy/polars.py b/datacompy/polars.py
index 65505e21..fcaa445d 100644
--- a/datacompy/polars.py
+++ b/datacompy/polars.py
@@ -159,7 +159,7 @@ def _get_comparators(self) -> List[BaseComparator]:
 
     @property
     def df1(self) -> pl.DataFrame:
-        """Get the first dataframe."""
+        """The first dataframe."""
         return self._df1
 
     @df1.setter
@@ -172,7 +172,7 @@ def df1(self, df1: pl.DataFrame) -> None:
 
     @property
     def df2(self) -> pl.DataFrame:
-        """Get the second dataframe."""
+        """The second dataframe."""
         return self._df2
 
     @df2.setter
diff --git a/datacompy/snowflake.py b/datacompy/snowflake.py
index 4b4b91d2..fa98bdd2 100644
--- a/datacompy/snowflake.py
+++ b/datacompy/snowflake.py
@@ -190,7 +190,7 @@ def _get_comparators(self) -> List[BaseComparator]:
 
     @property
     def df1(self) -> "sp.DataFrame":
-        """Get the first dataframe."""
+        """The first dataframe."""
         return self._df1
 
     @df1.setter
@@ -211,7 +211,7 @@ def df1(self, df1: tuple[Union[str, "sp.DataFrame"], str | None]) -> None:
 
     @property
     def df2(self) -> "sp.DataFrame":
-        """Get the second dataframe."""
+        """The second dataframe."""
         return self._df2
 
     @df2.setter
diff --git a/datacompy/spark.py b/datacompy/spark.py
index ee721b7c..12b30184 100644
--- a/datacompy/spark.py
+++ b/datacompy/spark.py
@@ -195,7 +195,7 @@ def _get_comparators(self) -> List[BaseComparator]:
 
     @property
     def df1(self) -> "pyspark.sql.DataFrame":
-        """Get the first dataframe."""
+        """The first dataframe."""
         return self._df1
 
     @df1.setter
@@ -208,7 +208,7 @@ def df1(self, df1: "pyspark.sql.DataFrame") -> None:
 
     @property
     def df2(self) -> "pyspark.sql.DataFrame":
-        """Get the second dataframe."""
+        """The second dataframe."""
         return self._df2
 
     @df2.setter

From d8eb485ba73130e6371594cf5b39cf788a5f8b2c Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Fri, 19 Jun 2026 11:55:59 -0400
Subject: [PATCH 22/23] fix: update snapshot files to reflect new DataFrame
 output format

---
 tests/snapshots/polars_unique_rows.txt     | 20 ++++++++++++++++----
 tests/snapshots/polars_with_mismatches.txt | 22 +++++++++++++++++-----
 2 files changed, 33 insertions(+), 9 deletions(-)

diff --git a/tests/snapshots/polars_unique_rows.txt b/tests/snapshots/polars_unique_rows.txt
index 7298f571..a516f0d5 100644
--- a/tests/snapshots/polars_unique_rows.txt
+++ b/tests/snapshots/polars_unique_rows.txt
@@ -42,11 +42,23 @@ Total number of values which compare unequal: 0
 Sample Rows Only in df1 (First 10 Columns)
 ------------------------------------------
 
-   id  val
-0   3   30
+shape: (1, 2)
+┌─────┬─────┐
+│ id  ┆ val │
+│ --- ┆ --- │
+│ i64 ┆ i64 │
+╞═════╪═════╡
+│ 3   ┆ 30  │
+└─────┴─────┘
 
 Sample Rows Only in df2 (First 10 Columns)
 ------------------------------------------
 
-   id  val
-0   4   40
\ No newline at end of file
+shape: (1, 2)
+┌─────┬─────┐
+│ id  ┆ val │
+│ --- ┆ --- │
+│ i64 ┆ i64 │
+╞═════╪═════╡
+│ 4   ┆ 40  │
+└─────┴─────┘
\ No newline at end of file
diff --git a/tests/snapshots/polars_with_mismatches.txt b/tests/snapshots/polars_with_mismatches.txt
index 1af99cd6..52f89939 100644
--- a/tests/snapshots/polars_with_mismatches.txt
+++ b/tests/snapshots/polars_with_mismatches.txt
@@ -50,8 +50,20 @@ val                  Int64           Int64                    1    79.0000
 Sample Rows with Unequal Values
 -------------------------------
 
-   id  val (df1)  val (df2)
-0   2         20         99
-
-   id  score (df1)  score (df2)
-0   2          2.0          2.5
\ No newline at end of file
+shape: (1, 3)
+┌─────┬───────────┬───────────┐
+│ id  ┆ val (df1) ┆ val (df2) │
+│ --- ┆ ---       ┆ ---       │
+│ i64 ┆ i64       ┆ i64       │
+╞═════╪═══════════╪═══════════╡
+│ 2   ┆ 20        ┆ 99        │
+└─────┴───────────┴───────────┘
+
+shape: (1, 3)
+┌─────┬─────────────┬─────────────┐
+│ id  ┆ score (df1) ┆ score (df2) │
+│ --- ┆ ---         ┆ ---         │
+│ i64 ┆ f64         ┆ f64         │
+╞═════╪═════════════╪═════════════╡
+│ 2   ┆ 2.0         ┆ 2.5         │
+└─────┴─────────────┴─────────────┘
\ No newline at end of file

From 96ab24fa6a336445441c8672dbfc56f791ed989b Mon Sep 17 00:00:00 2001
From: Faisal Dosani 
Date: Fri, 19 Jun 2026 14:14:06 -0400
Subject: [PATCH 23/23] fix: simplify mismatch detection logic in BaseCompare
 class

---
 datacompy/base.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/datacompy/base.py b/datacompy/base.py
index 94442b9d..40883e2b 100644
--- a/datacompy/base.py
+++ b/datacompy/base.py
@@ -336,11 +336,9 @@ def build_report_data(
         # ---- mismatch stats ------------------------------------------
         stat_list: List[MismatchStat] = []
         sample_list: List[Any] = []
-        any_mismatch = False
 
         for col in self.column_stats:
             if not col["all_match"]:
-                any_mismatch = True
                 stat_list.append(
                     MismatchStat(
                         column=col["column"],
@@ -360,7 +358,7 @@ def build_report_data(
                         )
                     )
 
-        if any_mismatch:
+        if stat_list:
             mismatch_stats = MismatchStats(
                 has_mismatches=True,
                 has_samples=len(sample_list) > 0 and sample_count > 0,