33
44import itertools
55import json
6- from datetime import date , datetime , timedelta
7- from decimal import Decimal
86
97import polars as pl
108import pytest
119
1210from diffly import compare_frames
1311from diffly .comparison import DataFrameComparison
14- from diffly .summary import (
15- SummaryData ,
16- SummaryDataColumn ,
17- SummaryDataColumnChange ,
18- SummaryDataRows ,
19- SummaryDataSchemas ,
20- _to_python ,
21- )
2212
2313
2414def _make_comparison () -> DataFrameComparison :
25- """A rich comparison with schema diffs, row diffs, and column diffs."""
15+ # Designed so every parametrized flag affects the expected JSON output:
16+ # - Same columns in both frames → schemas equal → slim suppresses schemas section
17+ # - status matches perfectly for joined rows → show_perfect_column_matches matters
18+ # - value differs for id=2 → always has a non-perfect column
19+ # - id=4 left-only, id=5 right-only → sample rows matter
2620 left = pl .DataFrame (
2721 {
2822 "id" : [1 , 2 , 3 , 4 ],
2923 "status" : ["a" , "b" , "c" , "d" ],
3024 "value" : [10.0 , 20.0 , 30.0 , 40.0 ],
31- "left_col" : ["x" , "y" , "z" , "w" ],
3225 }
3326 )
3427 right = pl .DataFrame (
3528 {
3629 "id" : [1 , 2 , 3 , 5 ],
37- "status" : ["a" , "x " , "x " , "e" ],
30+ "status" : ["a" , "b " , "c " , "e" ],
3831 "value" : [10.0 , 25.0 , 30.0 , 50.0 ],
39- "right_col" : ["p" , "q" , "r" , "s" ],
4032 }
4133 )
4234 return compare_frames (left , right , primary_key = "id" )
@@ -57,241 +49,73 @@ def test_summary_data_parametrized(
5749 sample_pk : bool ,
5850) -> None :
5951 comp = _make_comparison ()
52+ top_k = 3 if show_top_column_changes else 0
6053 summary = comp .summary (
6154 show_perfect_column_matches = show_perfect_column_matches ,
62- top_k_column_changes = 3 if show_top_column_changes else 0 ,
63- slim = slim ,
55+ top_k_column_changes = top_k ,
6456 sample_k_rows_only = 3 if sample_rows else 0 ,
6557 show_sample_primary_key_per_change = sample_pk ,
58+ slim = slim ,
6659 )
67- data = summary ._data
68-
69- assert isinstance (data , SummaryData )
70- assert data .equal is False
71- assert data .primary_key == ["id" ]
72-
73- # --- Schemas ---
74- schemas_equal = comp .schemas .equal ()
75- if slim and schemas_equal :
76- assert data .schemas is None
77- else :
78- assert isinstance (data .schemas , SummaryDataSchemas )
79- assert len (data .schemas .left_only_names ) > 0 # left_col
80- assert len (data .schemas .right_only_names ) > 0 # right_col
81-
82- # --- Rows ---
83- rows_equal = comp ._equal_rows ()
84- if slim and rows_equal :
85- assert data .rows is None
86- else :
87- assert isinstance (data .rows , SummaryDataRows )
88- assert data .rows .n_left == 4
89- assert data .rows .n_right == 4
90- assert data .rows .n_left_only is not None
91- assert data .rows .n_right_only is not None
92-
93- # --- Columns ---
94- assert data .columns is not None
95- match_rates = comp .fraction_same ()
96- for col in data .columns :
97- assert isinstance (col , SummaryDataColumn )
98- rate = match_rates [col .name ]
99- assert col .match_rate == rate
100- if show_top_column_changes and rate < 1 :
101- assert col .changes is not None
102- for change in col .changes :
103- assert isinstance (change , SummaryDataColumnChange )
104- if sample_pk :
105- assert isinstance (change .sample_pk , tuple )
106- assert len (change .sample_pk ) == 1
107- else :
108- assert change .sample_pk is None
109- else :
110- assert col .changes is None
111-
112- # --- Sample rows ---
113- if sample_rows :
114- assert data .sample_rows_left_only is not None
115- assert data .sample_rows_right_only is not None
116- assert len (data .sample_rows_left_only ) > 0
117- assert len (data .sample_rows_right_only ) > 0
118- for row in data .sample_rows_left_only :
119- assert isinstance (row , tuple )
120- for row in data .sample_rows_right_only :
121- assert isinstance (row , tuple )
122- else :
123- assert data .sample_rows_left_only is None
124- assert data .sample_rows_right_only is None
125-
126- # JSON roundtrip
127- parsed = json .loads (summary .to_json ())
128- assert isinstance (parsed , dict )
129- assert parsed ["equal" ] is False
130-
131-
132- def test_summary_data_equal_frames () -> None :
133- df = pl .DataFrame ({"id" : [1 , 2 ], "value" : [10.0 , 20.0 ]})
134- comp = compare_frames (df , df , primary_key = "id" )
135- data = comp .summary ()._data
136- assert data .equal is True
137- assert data .schemas is None
138- assert data .rows is None
139- assert data .columns is None
140- assert data .sample_rows_left_only is None
141- assert data .sample_rows_right_only is None
142-
143-
144- def test_summary_data_no_primary_key () -> None :
145- left = pl .DataFrame ({"a" : [1 , 2 ], "b" : [3.0 , 4.0 ]})
146- right = pl .DataFrame ({"a" : [1 , 2 ], "b" : [3.0 , 5.0 ]})
147- comp = compare_frames (left , right )
148- data = comp .summary ()._data
149- assert data .equal is False
150- assert data .primary_key is None
151- assert data .rows is not None
152- assert data .rows .n_left_only is None
153- assert data .rows .n_joined_equal is None
154- assert data .columns is None
155- assert data .sample_rows_left_only is None
156- assert data .sample_rows_right_only is None
157-
158-
159- def test_summary_data_hidden_columns () -> None :
160- left = pl .DataFrame ({"id" : [1 , 2 ], "secret" : ["a" , "b" ], "value" : [10.0 , 20.0 ]})
161- right = pl .DataFrame ({"id" : [1 , 2 ], "secret" : ["a" , "x" ], "value" : [10.0 , 25.0 ]})
162- comp = compare_frames (left , right , primary_key = "id" )
163- data = comp .summary (
164- top_k_column_changes = 3 ,
165- hidden_columns = ["secret" ],
166- )._data
167- assert data .columns is not None
168- for col in data .columns :
169- if col .name == "secret" :
170- assert col .changes is None
171- elif col .match_rate < 1 :
172- assert col .changes is not None
173-
174-
175- def test_summary_data_validate_hidden_pk_sample_rows () -> None :
176- df = pl .DataFrame ({"id" : ["a" , "b" , "c" ]})
177- comp = compare_frames (df , df .filter (pl .col ("id" ) == "a" ), primary_key = ["id" ])
178- with pytest .raises (ValueError , match = "Cannot show sample rows only" ):
179- comp .summary (sample_k_rows_only = 3 , hidden_columns = ["id" ])
180-
60+ result = json .loads (summary .to_json ())
61+
62+ # --- Build expected dictionary ---
63+ # Schemas: equal (same columns, same dtypes) → suppressed in slim mode
64+ expected_schemas : dict | None = None
65+ if not slim :
66+ expected_schemas = {
67+ "left_only_names" : [],
68+ "in_common" : [
69+ ["id" , "Int64" , "Int64" ],
70+ ["status" , "String" , "String" ],
71+ ["value" , "Float64" , "Float64" ],
72+ ],
73+ "right_only_names" : [],
74+ }
18175
182- def test_summary_data_validate_hidden_pk_sample_pk () -> None :
183- df = pl .DataFrame ({"id" : ["a" , "b" , "c" ], "value" : [1.0 , 2.0 , 3.0 ]})
184- comp = compare_frames (df , df .with_columns (pl .col ("value" ) + 1 ), primary_key = ["id" ])
185- with pytest .raises (ValueError , match = "Cannot show sample primary key" ):
186- comp .summary (
187- top_k_column_changes = 3 ,
188- show_sample_primary_key_per_change = True ,
189- hidden_columns = ["id" ],
76+ # Columns: status has 100% match rate, value has 2/3
77+ # show_perfect_column_matches controls whether the perfect status column appears
78+ value_col = {
79+ "name" : "value" ,
80+ "match_rate" : pytest .approx (2 / 3 ),
81+ "n_total_changes" : 1 if show_top_column_changes else 0 ,
82+ "changes" : (
83+ [
84+ {
85+ "old" : 20.0 ,
86+ "new" : 25.0 ,
87+ "count" : 1 ,
88+ "sample_pk" : [2 ] if sample_pk else None ,
89+ }
90+ ]
91+ if show_top_column_changes
92+ else None
93+ ),
94+ }
95+ expected_columns = []
96+ if show_perfect_column_matches :
97+ expected_columns .append (
98+ {"name" : "status" , "match_rate" : 1.0 , "n_total_changes" : 0 , "changes" : None }
19099 )
191-
192-
193- def test_summary_data_validate_zero_top_k_with_sample_pk () -> None :
194- df = pl .DataFrame ({"id" : ["a" , "b" ], "value" : [1.0 , 2.0 ]})
195- comp = compare_frames (df , df .with_columns (pl .col ("value" ) + 1 ), primary_key = ["id" ])
196- with pytest .raises (
197- ValueError ,
198- match = "Cannot show sample primary key per change when top_k_column_changes is 0" ,
199- ):
200- comp .summary (top_k_column_changes = 0 , show_sample_primary_key_per_change = True )
201-
202-
203- def test_summary_data_multiple_pk_columns () -> None :
204- left = pl .DataFrame ({"a" : [1 , 2 , 3 ], "b" : ["x" , "y" , "z" ], "val" : [10 , 20 , 30 ]})
205- right = pl .DataFrame ({"a" : [1 , 2 , 3 ], "b" : ["x" , "y" , "z" ], "val" : [10 , 99 , 30 ]})
206- comp = compare_frames (left , right , primary_key = ["a" , "b" ])
207- data = comp .summary (
208- top_k_column_changes = 3 ,
209- show_sample_primary_key_per_change = True ,
210- sample_k_rows_only = 3 ,
211- )._data
212- assert data .primary_key == ["a" , "b" ]
213- assert data .columns is not None
214- for col in data .columns :
215- if col .changes :
216- for change in col .changes :
217- assert isinstance (change .sample_pk , tuple )
218- assert len (change .sample_pk ) == 2
219-
220-
221- def test_summary_data_to_dict () -> None :
222- df = pl .DataFrame ({"id" : [1 , 2 ], "value" : [10.0 , 20.0 ]})
223- comp = compare_frames (df , df , primary_key = "id" )
224- d = comp .summary ()._data .to_dict ()
225- assert isinstance (d , dict )
226- assert d ["equal" ] is True
227-
228-
229- def test_summary_data_slim_suppresses_matching_sections () -> None :
230- left = pl .DataFrame ({"id" : [1 , 2 , 3 ], "value" : [10.0 , 20.0 , 30.0 ]})
231- right = pl .DataFrame ({"id" : [1 , 2 , 3 ], "value" : [10.0 , 25.0 , 30.0 ]})
232- comp = compare_frames (left , right , primary_key = "id" )
233- data = comp .summary (slim = True )._data
234-
235- # Schemas match -> None in slim mode
236- assert data .schemas is None
237- # Rows have differences (joined unequal) -> shown
238- assert data .rows is not None
239- # Columns have differences -> shown
240- assert data .columns is not None
241-
242-
243- @pytest .mark .parametrize (
244- "value, expected" ,
245- [
246- (datetime (2024 , 1 , 15 , 12 , 30 ), "2024-01-15T12:30:00" ),
247- (date (2024 , 1 , 15 ), "2024-01-15" ),
248- (timedelta (seconds = 5 ), 5.0 ),
249- (Decimal ("1.5" ), 1.5 ),
250- (42 , 42 ),
251- ("hello" , "hello" ),
252- (None , None ),
253- ],
254- )
255- def test_to_python (value : object , expected : object ) -> None :
256- assert _to_python (value ) == expected
257-
258-
259- def test_to_dict_with_typed_values () -> None :
260- comp = _make_comparison ()
261- summary = comp .summary (top_k_column_changes = 3 , sample_k_rows_only = 3 )
262- d = summary ._data .to_dict ()
263-
264- assert isinstance (d , dict )
265- assert d ["equal" ] is False
266- assert isinstance (d ["columns" ], list )
267- assert isinstance (d ["sample_rows_left_only" ], list )
268- # Verify roundtrip through JSON works
269- json_str = json .dumps (d )
270- parsed = json .loads (json_str )
271- assert parsed ["equal" ] is False
272- assert len (parsed ["columns" ]) > 0
273-
274-
275- def test_to_json_with_date_values () -> None :
276- left = pl .DataFrame ({"id" : [1 , 2 ], "d" : [date (2024 , 1 , 1 ), date (2024 , 6 , 1 )]})
277- right = pl .DataFrame ({"id" : [1 , 2 ], "d" : [date (2024 , 1 , 1 ), date (2024 , 12 , 1 )]})
278- comp = compare_frames (left , right , primary_key = "id" )
279- summary = comp .summary (top_k_column_changes = 3 )
280- parsed = json .loads (summary .to_json ())
281- assert parsed ["equal" ] is False
282- col = next (c for c in parsed ["columns" ] if c ["name" ] == "d" )
283- assert col ["changes" ] is not None
284- assert col ["changes" ][0 ]["old" ] == "2024-06-01"
285- assert col ["changes" ][0 ]["new" ] == "2024-12-01"
286-
287-
288- def test_summary_data_n_total_changes () -> None :
289- left = pl .DataFrame ({"id" : list (range (10 )), "val" : list (range (10 ))})
290- right = pl .DataFrame ({"id" : list (range (10 )), "val" : list (range (10 , 20 ))})
291- comp = compare_frames (left , right , primary_key = "id" )
292- data = comp .summary (top_k_column_changes = 3 )._data
293- assert data .columns is not None
294- col = next (c for c in data .columns if c .name == "val" )
295- assert col .changes is not None
296- assert len (col .changes ) == 3
297- assert col .n_total_changes == 10
100+ expected_columns .append (value_col )
101+
102+ expected = {
103+ "equal" : False ,
104+ "left_name" : "left" ,
105+ "right_name" : "right" ,
106+ "primary_key" : ["id" ],
107+ "schemas" : expected_schemas ,
108+ "rows" : {
109+ "n_left" : 4 ,
110+ "n_right" : 4 ,
111+ "n_left_only" : 1 ,
112+ "n_joined_equal" : 2 ,
113+ "n_joined_unequal" : 1 ,
114+ "n_right_only" : 1 ,
115+ },
116+ "columns" : expected_columns ,
117+ "sample_rows_left_only" : [[4 ]] if sample_rows else None ,
118+ "sample_rows_right_only" : [[5 ]] if sample_rows else None ,
119+ }
120+
121+ assert result == expected
0 commit comments