Skip to content

Commit e97b755

Browse files
init
1 parent b431ead commit e97b755

40 files changed

Lines changed: 916 additions & 25 deletions

File tree

diffly/cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,8 @@ def main(
139139
list[str],
140140
typer.Option(
141141
help=(
142-
"Metric presets to display per numerical column. Repeatable. "
142+
"Metric presets to display per column. Repeatable. Most presets apply "
143+
"to numerical columns only; ΔNull% applies to all columns. "
143144
f"Available: {', '.join(DEFAULT_METRICS)}."
144145
)
145146
),

diffly/comparison.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
lazy_len,
2626
make_and_validate_mapping,
2727
)
28-
from .metrics import MetricFn, _make_numeric_metric
28+
from .metrics import Metric, MetricFn, _make_numeric_metric
2929

3030
if TYPE_CHECKING: # pragma: no cover
3131
# NOTE: We cannot import at runtime as we're otherwise running into circular
@@ -920,7 +920,7 @@ def summary(
920920
right_name: str = Side.RIGHT,
921921
slim: bool = False,
922922
hidden_columns: list[str] | None = None,
923-
metrics: Mapping[str, MetricFn] | None = None,
923+
metrics: Mapping[str, MetricFn | Metric] | None = None,
924924
) -> Summary:
925925
"""Generate a summary of all aspects of the comparison.
926926
@@ -950,16 +950,19 @@ def summary(
950950
advanced users who are familiar with the summary format.
951951
hidden_columns: Columns for which no values are printed, e.g. because they
952952
contain sensitive information.
953-
metrics: Optional mapping from display label to a metric callable
954-
``(left_expr, right_expr) -> pl.Expr``. Each callable receives two
953+
metrics: Optional mapping from display label to a metric. A value may be a
954+
callable ``(left_expr, right_expr) -> pl.Expr`` or a
955+
:class:`~diffly.metrics.Metric`. Each callable receives two
955956
:class:`polars.Expr` referring to the left and right values of a single
956-
numerical column across all joined rows, and must return a scalar
957-
aggregation expression. See :doc:`/api/metrics` for the full list of
958-
presets and the :data:`~diffly.metrics.MetricFn` type. When ``None``
959-
(default), no metrics are computed; presets are not applied
960-
automatically. Metrics are only computed for numerical columns. Prefer
961-
short labels — the summary has a fixed width and many or long labels
962-
degrade rendering.
957+
column across all joined rows, and must return a scalar aggregation
958+
expression. Bare callables are only computed for numerical columns; wrap
959+
one in a :class:`~diffly.metrics.Metric` with a column selector to target
960+
other column types (e.g. ``Metric(fn, selector=cs.all())`` for a
961+
null-fraction metric across all columns). See :doc:`/api/metrics` for the
962+
full list of presets and the :data:`~diffly.metrics.MetricFn` type. When
963+
``None`` (default), no metrics are computed; presets are not applied
964+
automatically. Prefer short labels — the summary has a fixed width and
965+
many or long labels degrade rendering.
963966
964967
Returns:
965968
A summary which can be printed or written to a file.
@@ -976,7 +979,10 @@ def summary(
976979
from .summary import Summary
977980

978981
resolved_metrics = (
979-
{label: _make_numeric_metric(fn) for label, fn in metrics.items()}
982+
{
983+
label: v if isinstance(v, Metric) else _make_numeric_metric(v)
984+
for label, v in metrics.items()
985+
}
980986
if metrics is not None
981987
else None
982988
)

diffly/metrics.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
class Metric:
1515
"""A metric function paired with a column-applicability selector.
1616
17-
Internal only.
17+
Pass an instance as a value in the ``metrics`` mapping to compute a metric only for
18+
the columns matched by ``selector``. A bare :data:`MetricFn` passed instead defaults
19+
to numerical columns only.
1820
"""
1921

2022
fn: MetricFn
@@ -70,6 +72,15 @@ def mean_relative_deviation(left: pl.Expr, right: pl.Expr) -> pl.Expr:
7072
return ((right - left) / left).abs().mean()
7173

7274

75+
def null_fraction_change(left: pl.Expr, right: pl.Expr) -> pl.Expr:
76+
"""Change in the fraction of null entries, ``right - left``.
77+
78+
A positive value means the right side has proportionally more nulls than the left.
79+
Unlike the other presets, this applies to columns of any type.
80+
"""
81+
return right.is_null().mean() - left.is_null().mean()
82+
83+
7384
def quantile(q: float) -> MetricFn:
7485
"""Factory returning a metric that computes the ``q``-quantile of
7586
``right - left``."""
@@ -82,12 +93,13 @@ def _quantile(left: pl.Expr, right: pl.Expr) -> pl.Expr:
8293
return _quantile
8394

8495

85-
DEFAULT_METRICS: dict[str, MetricFn] = {
96+
DEFAULT_METRICS: dict[str, MetricFn | Metric] = {
8697
"Mean": mean,
8798
"Median": median,
8899
"Min": min,
89100
"Max": max,
90101
"Std": std,
91102
"Mean absolute deviation": mean_absolute_deviation,
92103
"Mean relative deviation": mean_relative_deviation,
104+
"ΔNull%": Metric(fn=null_fraction_change, selector=cs.all()),
93105
}

diffly/testing.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from ._compat import dy
2121
from .comparison import DataFrameComparison, compare_frames
22-
from .metrics import MetricFn
22+
from .metrics import Metric, MetricFn
2323

2424

2525
def assert_collection_equal(
@@ -40,7 +40,7 @@ def assert_collection_equal(
4040
right_name: str = Side.RIGHT,
4141
slim: bool = False,
4242
hidden_columns: list[str] | None = None,
43-
metrics: Mapping[str, MetricFn] | None = None,
43+
metrics: Mapping[str, MetricFn | Metric] | None = None,
4444
) -> None:
4545
"""Assert that two :mod:`dataframely` collections are equal.
4646
@@ -85,9 +85,11 @@ def assert_collection_equal(
8585
hidden_columns: Columns for which no values are printed, e.g. because they
8686
contain sensitive information.
8787
metrics: Optional mapping from display label to a metric callable
88-
``(left_expr, right_expr) -> pl.Expr``. See :mod:`diffly.metrics` for
89-
presets. When ``None`` (default), no metrics are computed; presets are
90-
not applied automatically.
88+
``(left_expr, right_expr) -> pl.Expr`` or a :class:`~diffly.metrics.Metric`.
89+
Bare callables are only computed for numerical columns; wrap one in a
90+
:class:`~diffly.metrics.Metric` with a column selector to target other column
91+
types. See :mod:`diffly.metrics` for presets. When ``None`` (default), no
92+
metrics are computed; presets are not applied automatically.
9193
9294
Raises:
9395
AssertionError: If the collections are not equal.
@@ -174,7 +176,7 @@ def assert_frame_equal(
174176
right_name: str = Side.RIGHT,
175177
slim: bool = False,
176178
hidden_columns: list[str] | None = None,
177-
metrics: Mapping[str, MetricFn] | None = None,
179+
metrics: Mapping[str, MetricFn | Metric] | None = None,
178180
) -> None:
179181
"""Assert that two :mod:`polars` data frames are equal.
180182
@@ -226,9 +228,11 @@ def assert_frame_equal(
226228
hidden_columns: Columns for which no values are printed, e.g. because they
227229
contain sensitive information.
228230
metrics: Optional mapping from display label to a metric callable
229-
``(left_expr, right_expr) -> pl.Expr``. See :mod:`diffly.metrics` for
230-
presets. When ``None`` (default), no metrics are computed; presets are
231-
not applied automatically.
231+
``(left_expr, right_expr) -> pl.Expr`` or a :class:`~diffly.metrics.Metric`.
232+
Bare callables are only computed for numerical columns; wrap one in a
233+
:class:`~diffly.metrics.Metric` with a column selector to target other column
234+
types. See :mod:`diffly.metrics` for presets. When ``None`` (default), no
235+
metrics are computed; presets are not applied automatically.
232236
233237
Raises:
234238
AssertionError: If the data frames are not equal.

docs/api/metrics.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@ Metrics
44

55
.. currentmodule:: diffly.metrics
66

7-
Metrics are scalar aggregations computed per numerical column when generating a
7+
Metrics are scalar aggregations computed per column when generating a
88
:meth:`~diffly.comparison.DataFrameComparison.summary`. Pass them via the
99
``metrics`` argument as a mapping from display label to a :data:`MetricFn`
1010
callable. :mod:`diffly.metrics` ships a set of presets; you can also supply
1111
your own callable ``(left_expr, right_expr) -> pl.Expr``.
1212

13+
A bare callable is only computed for numerical columns. To target other column
14+
types, wrap it in a :class:`Metric` with a column selector, e.g.
15+
``Metric(fn, selector=cs.string())`` or ``selector=cs.all()``.
16+
1317
.. autodata:: MetricFn
1418
:no-value:
1519

20+
.. autoclass:: Metric
21+
1622
Presets
1723
=======
1824

@@ -26,4 +32,5 @@ Presets
2632
std
2733
mean_absolute_deviation
2834
mean_relative_deviation
35+
null_fraction_change
2936
quantile

tests/cli/test_cli.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,27 @@ def test_cli_hidden_columns_alias_warns(tmp_path: Path) -> None:
8080
assert result.exit_code == 0
8181

8282

83+
def test_cli_null_fraction_metric(tmp_path: Path) -> None:
84+
left = pl.DataFrame({"id": [1, 2, 3], "status": ["a", "b", "c"]})
85+
right = pl.DataFrame({"id": [1, 2, 3], "status": ["a", None, "x"]})
86+
left.write_parquet(tmp_path / "left.parquet")
87+
right.write_parquet(tmp_path / "right.parquet")
88+
89+
result = runner.invoke(
90+
app,
91+
[
92+
str(tmp_path / "left.parquet"),
93+
str(tmp_path / "right.parquet"),
94+
"--primary-key",
95+
"id",
96+
"--metric",
97+
"ΔNull%",
98+
],
99+
)
100+
assert result.exit_code == 0
101+
assert "ΔNull%" in result.output
102+
103+
83104
def test_cli_unknown_metric(tmp_path: Path) -> None:
84105
left = pl.DataFrame({"id": [1, 2], "x": [1.0, 2.0]})
85106
right = pl.DataFrame({"id": [1, 2], "x": [1.0, 3.0]})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2+
┃ Diffly Summary ┃
3+
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
4+
Primary key: id
5+
6+
Schemas
7+
▔▔▔▔▔▔▔
8+
Schemas match exactly (column count: 3).
9+
10+
Rows
11+
▔▔▔▔
12+
Left count Right count
13+
5 (no change) 5
14+
15+
┏━┯━┯━┯━┯━┓╌╌╌┏━┯━┯━┯━┯━┓╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╮
16+
┃ │ │ │ │ ┃ = ┃ │ │ │ │ ┃ 2 equal (40.00%) │
17+
┠─┼─┼─┼─┼─┨╌╌╌┠─┼─┼─┼─┼─┨╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌├╴ 5 joined
18+
┃ │ │ │ │ ┃ ≠ ┃ │ │ │ │ ┃ 3 unequal (60.00%) │
19+
┗━┷━┷━┷━┷━┛╌╌╌┗━┷━┷━┷━┷━┛╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╯
20+
21+
Columns
22+
▔▔▔▔▔▔▔
23+
┏━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━┓
24+
┃ Column ┃ Match Rate ┃ Mean ┃ ΔNull% ┃ str_len_delta ┃
25+
┡━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━┩
26+
│ price │ 40.00% │ 0.75 │ -0.2 │ │
27+
│ status │ 40.00% │ │ 0.4 │ 0 │
28+
└────────┴────────────┴──────┴────────┴───────────────┘
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2+
┃ Diffly Summary ┃
3+
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
4+
Primary key: id
5+
6+
Schemas
7+
▔▔▔▔▔▔▔
8+
Schemas match exactly (column count: 3).
9+
10+
Rows
11+
▔▔▔▔
12+
Left count Right count
13+
5 (no change) 5
14+
15+
┏━┯━┯━┯━┯━┓╌╌╌┏━┯━┯━┯━┯━┓╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╮
16+
┃ │ │ │ │ ┃ = ┃ │ │ │ │ ┃ 2 equal (40.00%) │
17+
┠─┼─┼─┼─┼─┨╌╌╌┠─┼─┼─┼─┼─┨╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌├╴ 5 joined
18+
┃ │ │ │ │ ┃ ≠ ┃ │ │ │ │ ┃ 3 unequal (60.00%) │
19+
┗━┷━┷━┷━┷━┛╌╌╌┗━┷━┷━┷━┷━┛╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╯
20+
21+
Columns
22+
▔▔▔▔▔▔▔
23+
┏━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━┓
24+
┃ Column ┃ Match Rate ┃ Mean ┃ ΔNull% ┃ str_len_delta ┃
25+
┡━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━┩
26+
│ price │ 40.00% │ 0.75 │ -0.2 │ │
27+
│ status │ 40.00% │ │ 0.4 │ 0 │
28+
└────────┴────────────┴──────┴────────┴───────────────┘
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
Rows
2+
▔▔▔▔
3+
┏━┯━┯━┯━┯━┓╌╌╌┏━┯━┯━┯━┯━┓╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╮
4+
┃ │ │ │ │ ┃ = ┃ │ │ │ │ ┃ 2 equal (40.00%) │
5+
┠─┼─┼─┼─┼─┨╌╌╌┠─┼─┼─┼─┼─┨╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌├╴ 5 joined
6+
┃ │ │ │ │ ┃ ≠ ┃ │ │ │ │ ┃ 3 unequal (60.00%) │
7+
┗━┷━┷━┷━┷━┛╌╌╌┗━┷━┷━┷━┷━┛╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╯
8+
9+
Columns
10+
▔▔▔▔▔▔▔
11+
┏━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━┓
12+
┃ Column ┃ Match Rate ┃ Mean ┃ ΔNull% ┃ str_len_delta ┃
13+
┡━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━┩
14+
│ price │ 40.00% │ 0.75 │ -0.2 │ │
15+
│ status │ 40.00% │ │ 0.4 │ 0 │
16+
└────────┴────────────┴──────┴────────┴───────────────┘
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
Rows
2+
▔▔▔▔
3+
┏━┯━┯━┯━┯━┓╌╌╌┏━┯━┯━┯━┯━┓╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╮
4+
┃ │ │ │ │ ┃ = ┃ │ │ │ │ ┃ 2 equal (40.00%) │
5+
┠─┼─┼─┼─┼─┨╌╌╌┠─┼─┼─┼─┼─┨╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌├╴ 5 joined
6+
┃ │ │ │ │ ┃ ≠ ┃ │ │ │ │ ┃ 3 unequal (60.00%) │
7+
┗━┷━┷━┷━┷━┛╌╌╌┗━┷━┷━┷━┷━┛╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╯
8+
9+
Columns
10+
▔▔▔▔▔▔▔
11+
┏━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━┓
12+
┃ Column ┃ Match Rate ┃ Mean ┃ ΔNull% ┃ str_len_delta ┃
13+
┡━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━┩
14+
│ price │ 40.00% │ 0.75 │ -0.2 │ │
15+
│ status │ 40.00% │ │ 0.4 │ 0 │
16+
└────────┴────────────┴──────┴────────┴───────────────┘

0 commit comments

Comments
 (0)