dukit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dukit/diffing.py ADDED
@@ -0,0 +1,1461 @@
1
+
2
+ import pandas as pd
3
+ import openpyxl
4
+ import typing
5
+ import os
6
+
7
+ from .pandas import deduplicate
8
+ from .excel import format
9
+ from .typing import list_, str_
10
+ from .utils import (
11
+ log,
12
+ ensure_unique_string,
13
+ GREEN,
14
+ RED,
15
+ GREY_LIGHT,
16
+ GREEN_LIGHT,
17
+ ORANGE_LIGHT,
18
+ RED_LIGHT,
19
+ )
20
+
21
+
22
+ def diff(
23
+ old: pd.DataFrame | str,
24
+ new: pd.DataFrame | str,
25
+ uid=None,
26
+ mode='mix',
27
+ rename_cols: dict = None,
28
+ remove_cols: list | str = None,
29
+ remove_cols_by_suffix='',
30
+ retain_cols: list | str = None,
31
+ ignore_cols: list | str = None,
32
+ remove_sheets: list | str = None,
33
+ name='data',
34
+ linebreak='<br>',
35
+ suffix_old=' *old',
36
+ verbosity=3,
37
+ ) -> Diffs:
38
+ """
39
+ Calculates differences between dfs,
40
+ CSV or Excel files and returns a Diffs object.
41
+
42
+
43
+ Parameters
44
+ ----------
45
+
46
+ old, new : pd.DataFrame or filepath to CSV or Excel file
47
+
48
+ uid : identifies corresponding rows in old and new data.
49
+ * "COLNAME": use the specified col as unique identifier.
50
+ * None: try to find a suitable col automatically.
51
+ * False: use the index as unique identifier.
52
+
53
+ mode : how to display differences in the result.
54
+ * "mix": show rows and cols from old and new df
55
+ * "old": show only rows and cols from old df
56
+ * "new": show only rows and cols from new df
57
+ * "new+": also adds hidden columns with old values for comparison
58
+
59
+ rename_cols : dictionary to rename cols before comparison.
60
+ remove_cols : remove col(s) from both dfs.
61
+ remove_cols_by_suffix : remove cols that end with the specified suffix.
62
+ retain_cols : remove col(s) from both dfs, then readd to the result.
63
+ ignore_cols : keep col(s) but ignore them for comparison.
64
+ remove_sheets : remove sheets before comparison (only for excel files).
65
+
66
+
67
+ Examples
68
+ --------
69
+
70
+ basic usage:
71
+
72
+ >>> import dukit as dk
73
+ >>> diffs = dk.diff('old.xlsx', 'new.xlsx')
74
+ >>> diffs.show() #highlighted additions/removals/changes
75
+ >>> diffs.info()
76
+ >>> diffs.summary()
77
+ >>> diffs.details()
78
+ >>> diffs.to_excel('diffs.xlsx')
79
+ >>> diffs[0] #get the first diff
80
+ >>> diffs['Sheet1'] #get the diff for a specific sheet
81
+ """
82
+
83
+ diffs = Diffs(
84
+ old=old,
85
+ new=new,
86
+ uid=uid,
87
+ mode=mode,
88
+ rename_cols=rename_cols,
89
+ remove_cols=remove_cols,
90
+ remove_cols_by_suffix=remove_cols_by_suffix,
91
+ retain_cols=retain_cols,
92
+ ignore_cols=ignore_cols,
93
+ remove_sheets=remove_sheets,
94
+ name=name,
95
+ linebreak=linebreak,
96
+ suffix_old=suffix_old,
97
+ verbosity=verbosity,
98
+ )
99
+ return diffs
100
+
101
+
102
+
103
+
104
+ def rediff(
105
+ old: pd.DataFrame | str,
106
+ new: pd.DataFrame | str,
107
+ uid=None,
108
+ mode='new+',
109
+ rename_cols: dict = None,
110
+ remove_cols: list | str = 'diff',
111
+ remove_cols_by_suffix=' *old',
112
+ retain_cols: list | str = 'notes',
113
+ ignore_cols: list | str = None,
114
+ remove_sheets=['info', 'summary', 'details'],
115
+ name='data',
116
+ linebreak='\n',
117
+ suffix_old=' *old',
118
+ verbosity=3,
119
+ ) -> Diffs:
120
+ """
121
+ Same as diff(), but using defaults appropriate for
122
+ when the old data is already a diff output.
123
+ Can be imagined as diffing the data
124
+ while managing metadata appropriately.
125
+ """
126
+ diffs = Diffs(
127
+ old=old,
128
+ new=new,
129
+ uid=uid,
130
+ mode=mode,
131
+ rename_cols=rename_cols,
132
+ remove_cols=remove_cols,
133
+ remove_cols_by_suffix=remove_cols_by_suffix,
134
+ retain_cols=retain_cols,
135
+ ignore_cols=ignore_cols,
136
+ remove_sheets=remove_sheets,
137
+ name=name,
138
+ linebreak=linebreak,
139
+ suffix_old=suffix_old,
140
+ verbosity=verbosity,
141
+ )
142
+ return diffs
143
+
144
+
145
+
146
+
147
+ class Diffs:
148
+ """
149
+ Stores differences between (multiple) dfs.
150
+ For more detailed documentation see diff().
151
+ """
152
+
153
+ def __init__(
154
+ self,
155
+ old: pd.DataFrame | str,
156
+ new: pd.DataFrame | str,
157
+ uid=None,
158
+ mode='mix',
159
+ rename_cols: dict = None,
160
+ remove_cols: list | str = None,
161
+ remove_cols_by_suffix='',
162
+ retain_cols: list | str = None,
163
+ ignore_cols: list | str = None,
164
+ remove_sheets: list | str = None,
165
+ name='data',
166
+ linebreak='<br>',
167
+ suffix_old=' *old',
168
+ verbosity=3,
169
+ ):
170
+
171
+ self.linebreak = linebreak
172
+ self.verbosity = verbosity
173
+
174
+ if isinstance(old, pd.DataFrame) and isinstance(new, pd.DataFrame):
175
+ self.old = {name: old}
176
+ self.new = {name: new}
177
+ self.old_name = 'old'
178
+ self.new_name = 'new'
179
+ self.old_size = old.memory_usage(deep=True).sum() / 1024
180
+ self.new_size = new.memory_usage(deep=True).sum() / 1024
181
+
182
+ elif isinstance(old, str) and isinstance(new, str):
183
+ self.old, self.new = _get_data(
184
+ old,
185
+ new,
186
+ remove_sheets,
187
+ name,
188
+ verbosity,
189
+ )
190
+ self.old_name = old
191
+ self.new_name = new
192
+ self.old_size = os.path.getsize(old) / 1024
193
+ self.new_size = os.path.getsize(new) / 1024
194
+
195
+ else:
196
+ msg = (
197
+ 'ERROR: old and new must be 2 dfs,'
198
+ ' 2 csv files or 2 excel files.'
199
+ )
200
+ log(msg, 'dk.diffing.Diffs.__init__', verbosity)
201
+ raise ValueError(msg)
202
+
203
+
204
+ self.diffs = _get_diffs(
205
+ old=self.old,
206
+ new=self.new,
207
+ uid=uid,
208
+ mode=mode,
209
+ rename_cols=rename_cols,
210
+ remove_cols=remove_cols,
211
+ remove_cols_by_suffix=remove_cols_by_suffix,
212
+ retain_cols=retain_cols,
213
+ ignore_cols=ignore_cols,
214
+ linebreak=linebreak,
215
+ suffix_old=suffix_old,
216
+ verbosity=verbosity,
217
+ )
218
+
219
+
220
+ def __getitem__(self, key):
221
+ """
222
+ Get a specific Diff by sheetname or index.
223
+ """
224
+ if isinstance(key, str):
225
+ item = self.diffs[key]
226
+ else:
227
+ item = list(self.diffs.values())[key]
228
+ return item
229
+
230
+
231
+ def show(self, sheet=0):
232
+ """
233
+ Show a styled df with highlighted differences between dfs.
234
+ """
235
+ diff = self[sheet]
236
+ return diff.result
237
+
238
+
239
+ def info(self):
240
+ """
241
+ Basic information about the dfs.
242
+ """
243
+ data = {
244
+ 'data': [self.old_name, self.new_name],
245
+ 'size (KB)': [self.old_size, self.new_size],
246
+ }
247
+ info = pd.DataFrame(data)
248
+ return info
249
+
250
+
251
+ def summary(self) -> pd.DataFrame:
252
+ """
253
+ Summary of differences between dfs.
254
+ """
255
+
256
+ details = self.details()
257
+ cols_remove = [
258
+ 'all cols added',
259
+ 'all cols removed',
260
+ 'all rows added',
261
+ 'all rows removed',
262
+ ]
263
+ cols = [
264
+ col for col
265
+ in details.columns
266
+ if col not in cols_remove
267
+ ]
268
+ summary = details[cols]
269
+ # summary = summary.style.set_properties(**{
270
+ # 'text-align': 'left',
271
+ # 'white-space': 'normal',
272
+ # }) #type:ignore
273
+
274
+ return summary
275
+
276
+
277
+ def details(self) -> pd.DataFrame:
278
+ """
279
+ Detailed information about differences between dfs.
280
+ """
281
+
282
+ names = [name for name in self.diffs.keys()]
283
+ uids = [d.uid for d in self.diffs.values()]
284
+ in_both = []
285
+ for d in self.diffs.values():
286
+ if d.old.empty and d.new.empty:
287
+ in_both.append('not in old or new')
288
+ elif d.old.empty:
289
+ in_both.append('not in old')
290
+ elif d.new.empty:
291
+ in_both.append('not in new')
292
+ else:
293
+ in_both.append('yes')
294
+
295
+ all_cols_shared = [d.cols_shared for d in self.diffs.values()]
296
+ all_cols_added = [d.cols_added for d in self.diffs.values()]
297
+ all_cols_removed = [d.cols_removed for d in self.diffs.values()]
298
+
299
+ all_rows_shared = [d.rows_shared for d in self.diffs.values()]
300
+ all_rows_added = [d.rows_added for d in self.diffs.values()]
301
+ all_rows_removed = [d.rows_removed for d in self.diffs.values()]
302
+
303
+ cols_added_str = [str_(x) for x in all_cols_added]
304
+ cols_removed_str = [str_(x) for x in all_cols_removed]
305
+
306
+ rows_added_str = [str_(x) for x in all_rows_added]
307
+ rows_removed_str = [str_(x) for x in all_rows_removed]
308
+
309
+ cols_shared = [len(x) for x in all_cols_shared]
310
+ cols_added = [len(x) for x in all_cols_added]
311
+ cols_removed = [len(x) for x in all_cols_removed]
312
+
313
+ rows_shared = [len(x) for x in all_rows_shared]
314
+ rows_added = [len(x) for x in all_rows_added]
315
+ rows_removed = [len(x) for x in all_rows_removed]
316
+
317
+ vals_added = [sum(d.vals_added) for d in self.diffs.values()]
318
+ vals_removed = [sum(d.vals_removed) for d in self.diffs.values()]
319
+ vals_changed = [sum(d.vals_changed) for d in self.diffs.values()]
320
+
321
+ data = {
322
+ 'data': names,
323
+ 'uid': uids,
324
+ 'in both dfs': in_both,
325
+ 'cols shared': cols_shared,
326
+ 'cols added': cols_added,
327
+ 'cols removed': cols_removed,
328
+ 'rows shared': rows_shared,
329
+ 'rows added': rows_added,
330
+ 'rows removed': rows_removed,
331
+ 'vals added': vals_added,
332
+ 'vals removed': vals_removed,
333
+ 'vals changed': vals_changed,
334
+ 'all cols added': cols_added_str,
335
+ 'all cols removed': cols_removed_str,
336
+ 'all rows added': rows_added_str,
337
+ 'all rows removed': rows_removed_str,
338
+ }
339
+ details = (
340
+ pd.DataFrame(data)
341
+ .convert_dtypes()
342
+ .astype('string')
343
+ .fillna('')
344
+ .replace('0', '')
345
+ .replace('[]', '')
346
+ .replace(r'{}', '')
347
+ )
348
+ # details = details.style.set_properties(**{
349
+ # 'text-align': 'left',
350
+ # 'white-space': 'normal',
351
+ # }) #type:ignore
352
+
353
+ return details
354
+
355
+
356
+ def to_excel(
357
+ self,
358
+ path,
359
+ index=False,
360
+ apply_format=True,
361
+ freeze_panes='C2',
362
+ hide_info=True,
363
+ hide_details=True,
364
+ hide_summary=False,
365
+ ):
366
+ """
367
+ Export diff results to an Excel file with formatting.
368
+
369
+ Creates an Excel file containing diff metadata and individual
370
+ sheets for each comparison with highlighted differences. Applies
371
+ Excel-specific formatting and hides columns with old values.
372
+
373
+
374
+ Parameters
375
+ ----------
376
+ path : str
377
+ File path for the output Excel file
378
+ index : bool, default False
379
+ Whether to include row indices in the Excel output
380
+ apply_format : bool, default True
381
+ Whether to apply formatting to the Excel file
382
+ freeze_panes : str, default 'C2'
383
+ Cell reference for freezing panes in the Excel file
384
+ hide_info : bool, default True
385
+ Whether to hide the info sheet in the Excel file
386
+ hide_details : bool, default True
387
+ Whether to hide the details sheet in the Excel file
388
+ hide_summary : bool, default False
389
+ Whether to hide the summary sheet in the Excel file
390
+ """
391
+
392
+ msg = f'DEBUG: saving differences to "{path}"'
393
+ context = 'dk.diffing.Diffs.to_excel'
394
+ log(msg, context, self.verbosity)
395
+
396
+ with pd.ExcelWriter(path) as writer:
397
+
398
+ info = self.info()
399
+ summary = self.summary()
400
+ details = self.details()
401
+
402
+ sheets = self.diffs.keys()
403
+ sheet_info = ensure_unique_string(
404
+ 'info',
405
+ sheets,
406
+ )
407
+ sheet_summary = ensure_unique_string(
408
+ 'summary',
409
+ sheets,
410
+ )
411
+ sheet_details = ensure_unique_string(
412
+ 'details',
413
+ sheets,
414
+ )
415
+
416
+ info.to_excel(
417
+ writer,
418
+ sheet_name=sheet_info,
419
+ index=False,
420
+ )
421
+ log('DEBUG: info sheet saved', context, self.verbosity)
422
+
423
+ summary.to_excel(
424
+ writer,
425
+ sheet_name=sheet_summary,
426
+ index=False,
427
+ )
428
+ log('DEBUG: summary sheet saved', context, self.verbosity)
429
+
430
+ details.to_excel(
431
+ writer,
432
+ sheet_name=sheet_details,
433
+ index=False,
434
+ )
435
+ log('DEBUG: details sheet saved', context, self.verbosity)
436
+
437
+ #diff sheets
438
+ for sheet, diff in self.diffs.items():
439
+ diff.result.to_excel(
440
+ writer,
441
+ sheet_name=sheet,
442
+ index=index,
443
+ )
444
+ msg = f'DEBUG: diff sheet saved: "{sheet}"'
445
+ log(msg, context, self.verbosity)
446
+
447
+
448
+ #format excel file
449
+ if apply_format:
450
+ wb = openpyxl.load_workbook(path)
451
+
452
+ #metadata sheets
453
+ sheets_hide = []
454
+ if hide_info:
455
+ sheets_hide.append(sheet_info)
456
+ if hide_summary:
457
+ sheets_hide.append(sheet_summary)
458
+ if hide_details:
459
+ sheets_hide.append(sheet_details)
460
+ format(
461
+ path,
462
+ sheet=[sheet_info, sheet_summary, sheet_details],
463
+ hide_sheets=sheets_hide,
464
+ freeze_panes='B2',
465
+ openpyxl_workbook=wb,
466
+ )
467
+ msg = 'DEBUG: info, summary, details sheets formatted'
468
+ log(msg, context, self.verbosity)
469
+
470
+ #diff sheets
471
+ for sheet, diff in self.diffs.items():
472
+ if diff._metadata_col_mapping:
473
+ cols_hide = list(diff._metadata_col_mapping.values())
474
+ else:
475
+ cols_hide = None
476
+ format(
477
+ path,
478
+ sheet=diff.name,
479
+ hide_cols=cols_hide,
480
+ freeze_panes=freeze_panes,
481
+ openpyxl_workbook=wb,
482
+ )
483
+ msg = f'DEBUG: diff sheet formatted: "{diff.name}"'
484
+ log(msg, context, self.verbosity)
485
+
486
+ log(f'INFO: differences saved to "{path}"', context, self.verbosity)
487
+
488
+
489
+ def print(self):
490
+ print(self.__str__())
491
+ return self
492
+
493
+ def __str__(self):
494
+ string = 'Diff objects:'
495
+ for diff in self.diffs.values():
496
+ string += '\n ' + str(diff).replace('\n', '\n ')
497
+ return string
498
+
499
+ def __repr__(self) -> str:
500
+ return self.__str__()
501
+
502
+ def __iter__(self):
503
+ for diff in self.diffs.values():
504
+ yield diff
505
+
506
+
507
+
508
+
509
+ def _get_data(
510
+ old: str,
511
+ new: str,
512
+ remove_sheets: list[str] | str = None,
513
+ name='data',
514
+ verbosity=3,
515
+ ):
516
+
517
+ both_csv = (
518
+ isinstance(old, str)
519
+ and isinstance(new, str)
520
+ and old.endswith('.csv')
521
+ and new.endswith('.csv')
522
+ )
523
+ both_xlsx = (
524
+ isinstance(old, str)
525
+ and isinstance(new, str)
526
+ and old.endswith('.xlsx')
527
+ and new.endswith('.xlsx')
528
+ )
529
+
530
+ if both_csv:
531
+ data_old = {name: pd.read_csv(old)}
532
+ data_new = {name: pd.read_csv(new)}
533
+
534
+ elif both_xlsx:
535
+ data_old, data_new = _read_excel(
536
+ old,
537
+ new,
538
+ remove_sheets,
539
+ verbosity,
540
+ )
541
+
542
+ else:
543
+ msg = (
544
+ 'ERROR: old and new must be 2 dfs,'
545
+ ' 2 csv files or 2 excel files.'
546
+ )
547
+ log(msg, 'dk.diffing._get_data', verbosity)
548
+ raise ValueError(msg)
549
+
550
+ return data_old, data_new
551
+
552
+
553
+
554
+ def _read_excel(
555
+ old: str,
556
+ new: str,
557
+ remove_sheets: list[str] | str = None,
558
+ verbosity=3,
559
+ ) -> tuple[dict[str, pd.DataFrame], dict[str, pd.DataFrame]]:
560
+
561
+ data_old = {}
562
+ data_new = {}
563
+ sheets_old = pd.ExcelFile(old).sheet_names
564
+ sheets_new = pd.ExcelFile(new).sheet_names
565
+ sheets_all = pd.Index(sheets_new).union(pd.Index(sheets_old), sort=False)
566
+ sheets_ignore = list_(remove_sheets)
567
+
568
+ for sheet in sheets_all:
569
+ if sheet in sheets_ignore:
570
+ msg = f'TRACE: removing sheet {sheet!r} before diffing'
571
+ log(msg, 'dk.diffing._read_excel', verbosity)
572
+ continue
573
+ if sheet in sheets_old:
574
+ data_old[sheet] = pd.read_excel(old, sheet_name=sheet)
575
+ if sheet in sheets_new:
576
+ data_new[sheet] = pd.read_excel(new, sheet_name=sheet)
577
+
578
+ return data_old, data_new
579
+
580
+
581
+
582
+
583
+ def _get_diffs(
584
+ old: dict[str, pd.DataFrame],
585
+ new: dict[str, pd.DataFrame],
586
+ uid=None,
587
+ mode='mix',
588
+ rename_cols: dict = None,
589
+ remove_cols: list | str = None,
590
+ remove_cols_by_suffix='',
591
+ retain_cols: list | str = None,
592
+ ignore_cols: list | str = None,
593
+ linebreak='<br>',
594
+ suffix_old=' *old',
595
+ verbosity=3,
596
+ ) -> dict[str, Diff]:
597
+
598
+ diffs = {}
599
+ sheets_all = pd.Index(new.keys()).union(pd.Index(old.keys()), sort=False)
600
+
601
+ for sheet in sheets_all:
602
+
603
+ if sheet in new and sheet in old:
604
+ d = _get_single_diff(
605
+ old=old[sheet],
606
+ new=new[sheet],
607
+ uid=uid,
608
+ mode=mode,
609
+ rename_cols=rename_cols,
610
+ retain_cols=retain_cols,
611
+ remove_cols=remove_cols,
612
+ remove_cols_by_suffix=remove_cols_by_suffix,
613
+ ignore_cols=ignore_cols,
614
+ name=sheet,
615
+ linebreak=linebreak,
616
+ suffix_old=suffix_old,
617
+ verbosity=verbosity,
618
+ )
619
+
620
+ elif sheet in new:
621
+ d = _get_single_diff(
622
+ old=pd.DataFrame(),
623
+ new=new[sheet],
624
+ uid=uid,
625
+ mode=mode,
626
+ rename_cols=rename_cols,
627
+ retain_cols=retain_cols,
628
+ remove_cols=remove_cols,
629
+ remove_cols_by_suffix=remove_cols_by_suffix,
630
+ ignore_cols=ignore_cols,
631
+ name=sheet,
632
+ linebreak=linebreak,
633
+ suffix_old=suffix_old,
634
+ verbosity=verbosity,
635
+ )
636
+
637
+ elif sheet in old:
638
+ d = _get_single_diff(
639
+ old=old[sheet],
640
+ new=pd.DataFrame(),
641
+ uid=uid,
642
+ mode=mode,
643
+ rename_cols=rename_cols,
644
+ retain_cols=retain_cols,
645
+ remove_cols=remove_cols,
646
+ remove_cols_by_suffix=remove_cols_by_suffix,
647
+ ignore_cols=ignore_cols,
648
+ name=sheet,
649
+ linebreak=linebreak,
650
+ suffix_old=suffix_old,
651
+ verbosity=verbosity,
652
+ )
653
+
654
+ diffs[sheet] = d
655
+
656
+
657
+ return diffs
658
+
659
+
660
+
661
+ def _get_single_diff(
662
+ old: pd.DataFrame,
663
+ new: pd.DataFrame,
664
+ uid=None,
665
+ mode='mix',
666
+ rename_cols: dict = None,
667
+ remove_cols: list | str = None,
668
+ remove_cols_by_suffix='',
669
+ retain_cols: list | str = None,
670
+ ignore_cols: list | str = None,
671
+ name='data',
672
+ linebreak='<br>',
673
+ suffix_old=' *old',
674
+ verbosity=3,
675
+ ) -> Diff:
676
+
677
+ if mode not in ('mix', 'old', 'new', 'new+'):
678
+ msg = f'ERROR: unknown mode "{mode}"'
679
+ log(msg, 'dk.diffing._get_single_diff', verbosity)
680
+ raise ValueError(f'Unknown mode "{mode}"')
681
+
682
+ d = Diff(
683
+ old=old,
684
+ new=new,
685
+ mode=mode,
686
+ name=name,
687
+ linebreak=linebreak,
688
+ suffix_old=suffix_old,
689
+ verbosity=verbosity,
690
+ )
691
+
692
+ if d.old.empty or d.new.empty:
693
+ d = _handle_edgecases(d)
694
+ return d
695
+
696
+ d = _process_dfs(d)
697
+ d = _rename_cols(d, rename_cols)
698
+ d = _remove_cols(d, list_(remove_cols))
699
+ d = _remove_cols_by_suffix(d, remove_cols_by_suffix)
700
+
701
+ d = _set_uid(d, uid)
702
+ d = _retain_cols(d, list_(retain_cols)) #depends on _set_uid()
703
+ d = _ignore_cols(d, list_(ignore_cols))
704
+
705
+ d = _create_working_values(d)
706
+ d = _create_diff_col(d)
707
+ d = _create_style(d)
708
+
709
+ d = _get_row_col_diffs(d)
710
+ d = _create_meta_cols(d) #depends on _get_row_col_diffs()
711
+ d = _align_dtypes(d) #depends on _get_row_col_diffs()
712
+ d = _get_val_diffs(d) #depends on _get_row_col_diffs() and _align_dtypes()
713
+
714
+ d = _populate_row_col_styles(d) #depends on _get_row_col_diffs()
715
+ d = _populate_val_styles(d) #depends on _get_val_diffs()
716
+ d = _populate_meta_cols(d) #depends on _get_row_col_diffs() and _get_val_diffs()
717
+ d = _populate_diff_col(d) #depends on _get_row_col_diffs() and _get_val_diffs()
718
+
719
+ d = _add_retained_cols(d)
720
+ d = _add_diff_col(d)
721
+ d = _add_uid_col(d)
722
+ d = _apply_style(d)
723
+
724
+ return d
725
+
726
+
727
+
728
+
729
+ class Diff:
730
+ """
731
+ Stores differences between 2 dfs.
732
+ For more detailed documentation see diff().
733
+ """
734
+
735
+ def __init__(
736
+ self,
737
+ old: pd.DataFrame,
738
+ new: pd.DataFrame,
739
+ uid=None,
740
+ mode='mix',
741
+ name='data',
742
+ linebreak='<br>',
743
+ suffix_old=' *old',
744
+ verbosity=3,
745
+ ):
746
+
747
+ self.old = old
748
+ self.new = new
749
+ self.uid = uid
750
+ self.mode = mode
751
+ self.name = name
752
+ self.verbosity = verbosity
753
+ self.linebreak = linebreak
754
+ self.suffix_old = suffix_old
755
+
756
+ #determined by _get_row_col_diffs()
757
+ self.cols_shared: pd.Index = pd.Index([], dtype='string')
758
+ self.cols_added: pd.Index = pd.Index([], dtype='string')
759
+ self.cols_removed: pd.Index = pd.Index([], dtype='string')
760
+ self.rows_shared: pd.Index = pd.Index([], dtype='string')
761
+ self.rows_added: pd.Index = pd.Index([], dtype='string')
762
+ self.rows_removed: pd.Index = pd.Index([], dtype='string')
763
+
764
+ #determined by _get_val_diffs()
765
+ self.vals_added: pd.Series = pd.Series(dtype='int')
766
+ self.vals_removed: pd.Series = pd.Series(dtype='int')
767
+ self.vals_changed: pd.Series = pd.Series(dtype='int')
768
+
769
+ #helper attributes for internal use
770
+ self._df_retained: pd.DataFrame
771
+ self._cols_ignore: list[str]
772
+ self._metadata_col_mapping: dict[str, str]
773
+ self._mask_added: pd.DataFrame
774
+ self._mask_removed: pd.DataFrame
775
+ self._mask_changed: pd.DataFrame
776
+ self._diff_col: pd.Series
777
+ self._values: pd.DataFrame
778
+ self._style: pd.DataFrame
779
+ self.result: pd.io.formats.style.Styler
780
+
781
+
782
+
783
+ def __str__(self):
784
+
785
+ txt = (
786
+ f'----------------Diff object [d]----------------\n'
787
+ f'name: {self.name!r}\n'
788
+ f'mode: {self.mode!r}\n'
789
+ )
790
+ if self.old.empty and self.new.empty:
791
+ txt += 'both dfs are empty\n'
792
+ elif self.old.empty:
793
+ txt += 'old df is empty\n'
794
+ elif self.new.empty:
795
+ txt += 'new df is empty\n'
796
+ elif self.old.equals(self.new):
797
+ txt += 'dfs are identical\n'
798
+ else:
799
+ txt += (
800
+ f'cols shared: {len(self.cols_shared)}\n'
801
+ f'cols added: {len(self.cols_added)}\n'
802
+ f'cols removed: {len(self.cols_removed)}\n'
803
+ f'rows shared: {len(self.rows_shared)}\n'
804
+ f'rows added: {len(self.rows_added)}\n'
805
+ f'rows removed: {len(self.rows_removed)}\n'
806
+ f'vals added: {sum(self.vals_added)}\n'
807
+ f'vals removed: {sum(self.vals_removed)}\n'
808
+ f'vals changed: {sum(self.vals_changed)}\n'
809
+ )
810
+ txt += (
811
+ '>>>d.result\n'
812
+ '>>>d.summary\n'
813
+ '>>>d.details\n'
814
+ )
815
+ txt += '----------------Diff object end----------------\n'
816
+
817
+ return txt
818
+
819
+
820
+ def __repr__(self) -> str:
821
+ return self.__str__()
822
+
823
+
824
+
825
+
826
+ def _handle_edgecases(d: Diff) -> Diff:
827
+ """
828
+ Handle edge cases where one or both dfs are empty.
829
+ """
830
+
831
+ if d.old.empty and d.new.empty:
832
+ values = pd.DataFrame({'diff': ['empty dfs']})
833
+ style = pd.DataFrame(
834
+ f'background-color: {GREY_LIGHT};',
835
+ index=values.index,
836
+ columns=values.columns,
837
+ )
838
+
839
+ elif d.old.empty:
840
+ values = d.new.copy()
841
+ col_diff = ensure_unique_string('diff', values.columns)
842
+ values.insert(0, col_diff, 'df added')
843
+ style = pd.DataFrame(
844
+ f'background-color: {GREEN};',
845
+ index=values.index,
846
+ columns=values.columns,
847
+ )
848
+
849
+ elif d.new.empty:
850
+ values = d.old.copy()
851
+ col_diff = ensure_unique_string('diff', values.columns)
852
+ values.insert(0, col_diff, 'df removed')
853
+ style = pd.DataFrame(
854
+ f'background-color: {RED};',
855
+ index=values.index,
856
+ columns=values.columns,
857
+ )
858
+
859
+ d.result = values.style.apply(lambda x: style, axis=None)
860
+ d.result = d.result.set_properties(white_space='normal')
861
+ d._values = values
862
+ d._style = style
863
+
864
+ d._df_retained = pd.DataFrame()
865
+ d._cols_ignore = []
866
+ d._metadata_col_mapping = {}
867
+ d._mask_added = pd.DataFrame(dtype='bool')
868
+ d._mask_removed = pd.DataFrame(dtype='bool')
869
+ d._mask_changed = pd.DataFrame(dtype='bool')
870
+ d._diff_col = pd.Series(dtype='string')
871
+
872
+ return d
873
+
874
+
875
+
876
+ def _process_dfs(d: Diff) -> Diff:
877
+ d.old = d.old.convert_dtypes()
878
+ d.new = d.new.convert_dtypes()
879
+ return d
880
+
881
+
882
+
883
+ def _rename_cols(d: Diff, cols_mapping: dict | None) -> Diff:
884
+
885
+ if cols_mapping is None:
886
+ return d
887
+
888
+ cols_mapping_old = {
889
+ k: v
890
+ for k, v
891
+ in cols_mapping.items()
892
+ if k in d.old.columns
893
+ }
894
+ cols_mapping_new = {
895
+ k: v
896
+ for k, v
897
+ in cols_mapping.items()
898
+ if k in d.new.columns
899
+ }
900
+
901
+ d.old = d.old.rename(columns=cols_mapping_old)
902
+ d.new = d.new.rename(columns=cols_mapping_new)
903
+
904
+ return d
905
+
906
+
907
+
908
+ def _remove_cols(d: Diff, cols: list) -> Diff:
909
+
910
+ if not cols:
911
+ return d
912
+
913
+ cols_remove_old = (
914
+ d.old.columns.
915
+ intersection(cols)
916
+ .to_list()
917
+ )
918
+ cols_remove_new = (
919
+ d.new.columns
920
+ .intersection(cols)
921
+ .to_list()
922
+ )
923
+
924
+ d.old = d.old.drop(columns=cols_remove_old)
925
+ d.new = d.new.drop(columns=cols_remove_new)
926
+
927
+ return d
928
+
929
+
930
+
931
+ def _remove_cols_by_suffix(d: Diff, suffix: str) -> Diff:
932
+ """
933
+ Remove cols from both dfs ending with
934
+ a specific suffix before diffing.
935
+ """
936
+
937
+ if not suffix:
938
+ return d
939
+
940
+ cols_remove_old = [
941
+ col
942
+ for col
943
+ in d.old.columns
944
+ if str(col).endswith(suffix)
945
+ ]
946
+ cols_remove_new = [
947
+ col
948
+ for col
949
+ in d.new.columns
950
+ if str(col).endswith(suffix)
951
+ ]
952
+
953
+ d.old = d.old.drop(columns=cols_remove_old)
954
+ d.new = d.new.drop(columns=cols_remove_new)
955
+
956
+ return d
957
+
958
+
959
+
960
+ def _set_uid(d: Diff, uid: typing.Any) -> Diff:
961
+ """
962
+ set unique identifier (uid) column for
963
+ comparing rows between old and new dfs.
964
+ """
965
+
966
+ if uid is False:
967
+ msg = 'TRACE: using index as uid'
968
+ log(msg, 'dk.diffing._set_uid', d.verbosity)
969
+ cols_all = d.old.columns.union(d.new.columns)
970
+ uid = ensure_unique_string('index', cols_all)
971
+ d.uid = '<index>'
972
+
973
+ elif uid is None:
974
+ msg = 'TRACE: searching for suitable uid column'
975
+ log(msg, 'dk.diffing._set_uid', d.verbosity)
976
+ uid = _find_uid(
977
+ d.old,
978
+ d.new,
979
+ d.name,
980
+ d.verbosity,
981
+ )
982
+ d.old.index = d.old[uid]
983
+ d.new.index = d.new[uid]
984
+ d.old.drop(columns=uid, inplace=True)
985
+ d.new.drop(columns=uid, inplace=True)
986
+ d.uid = uid
987
+
988
+ elif uid in d.old.columns and uid in d.new.columns:
989
+ d.old.index = d.old[uid]
990
+ d.new.index = d.new[uid]
991
+ d.old.drop(columns=uid, inplace=True)
992
+ d.new.drop(columns=uid, inplace=True)
993
+ d.uid = uid
994
+
995
+ else:
996
+ raise ValueError(f"UID column {uid!r} not found in both dataframes.")
997
+
998
+
999
+ if not d.old.index.is_unique:
1000
+ d.old.index = deduplicate(
1001
+ d.old.index,
1002
+ name=f'{uid} in old df',
1003
+ verbosity=d.verbosity,
1004
+ )
1005
+ d.new.index = d.new.index.astype(str)
1006
+
1007
+ if not d.new.index.is_unique:
1008
+ d.new.index = deduplicate(
1009
+ d.new.index,
1010
+ name=f'{uid} in new df',
1011
+ verbosity=d.verbosity,
1012
+ )
1013
+ d.old.index = d.old.index.astype(str)
1014
+
1015
+ return d
1016
+
1017
+
1018
+
1019
+ def _find_uid(
1020
+ old: pd.DataFrame,
1021
+ new: pd.DataFrame,
1022
+ name='data',
1023
+ verbosity=3,
1024
+ ) -> typing.Any:
1025
+
1026
+ uids_potential = new.columns.intersection(old.columns)
1027
+ uids_by_uniqueness = {}
1028
+
1029
+ for uid in uids_potential:
1030
+ unique_in_old = pd.Index(old[uid].dropna()).unique()
1031
+ unique_in_new = pd.Index(new[uid].dropna()).unique()
1032
+ unique_shared = unique_in_new.intersection(unique_in_old)
1033
+ uids_by_uniqueness[uid] = len(unique_shared)
1034
+
1035
+ uids_by_uniqueness = sorted(
1036
+ uids_by_uniqueness.items(),
1037
+ key=lambda item: item[1],
1038
+ reverse=True,
1039
+ )
1040
+
1041
+ if len(uids_by_uniqueness) > 0:
1042
+ uid = uids_by_uniqueness[0][0]
1043
+ msg = f'DEBUG: found uid {uid!r} for {name!r}'
1044
+ log(msg, 'dukit.diffing._find_uid', verbosity)
1045
+ else:
1046
+ uid = ''
1047
+ msg = f'DEBUG: no uid found. using index for {name!r}'
1048
+ log(msg, 'dukit.diffing._find_uid', verbosity)
1049
+
1050
+ return uid
1051
+
1052
+
1053
+
1054
+ def _retain_cols(d: Diff, cols: list) -> Diff:
1055
+ """
1056
+ Remove cols from both dfs before diffing,
1057
+ then readd the ones from the old df to
1058
+ the diff result later.
1059
+ """
1060
+
1061
+ if not cols:
1062
+ d._df_retained = pd.DataFrame()
1063
+ return d
1064
+
1065
+ cols_retain_old = d.old.columns.intersection(cols)
1066
+ cols_retain_new = d.new.columns.intersection(cols)
1067
+
1068
+ df_retained = d.old[cols_retain_old].copy()
1069
+
1070
+ d.old = d.old.drop(columns=cols_retain_old)
1071
+ d.new = d.new.drop(columns=cols_retain_new)
1072
+ d._df_retained = df_retained
1073
+
1074
+ return d
1075
+
1076
+
1077
+
1078
+ def _ignore_cols(d: Diff, cols: list) -> Diff:
1079
+ """
1080
+ Ignore columns for diffing,
1081
+ but keep them in both dfs.
1082
+ """
1083
+
1084
+ if not cols:
1085
+ d._cols_ignore = []
1086
+ return d
1087
+
1088
+ d._cols_ignore = (
1089
+ d.old.columns
1090
+ .union(d.new.columns)
1091
+ .intersection(cols)
1092
+ .to_list()
1093
+ )
1094
+
1095
+ return d
1096
+
1097
+
1098
+
1099
+ def _create_working_values(d: Diff) -> Diff:
1100
+
1101
+ if d.mode == 'mix':
1102
+ rows_old_only = d.old.index.difference(d.new.index)
1103
+ cols_old_only = d.old.columns.difference(d.new.columns)
1104
+ d._values = pd.concat([d.new, d.old.loc[:, cols_old_only]], axis=1)
1105
+ d._values.loc[rows_old_only, :] = d.old.loc[rows_old_only, :]
1106
+
1107
+ elif d.mode == 'old':
1108
+ d._values = d.old.copy()
1109
+
1110
+ elif d.mode in ('new', 'new+'):
1111
+ d._values = d.new.copy()
1112
+
1113
+ return d
1114
+
1115
+
1116
+
1117
+ def _create_diff_col(d: Diff) -> Diff:
1118
+ d._diff_col = pd.Series(
1119
+ '',
1120
+ index=d._values.index,
1121
+ dtype='string',
1122
+ )
1123
+ return d
1124
+
1125
+
1126
+
1127
+ def _create_style(d: Diff) -> Diff:
1128
+ d._style = pd.DataFrame(
1129
+ '',
1130
+ index=d._values.index,
1131
+ columns=d._values.columns,
1132
+ dtype='string',
1133
+ )
1134
+ return d
1135
+
1136
+
1137
+
1138
+ def _get_row_col_diffs(d: Diff) -> Diff:
1139
+
1140
+ d.cols_shared = (
1141
+ d.new.columns
1142
+ .intersection(d.old.columns)
1143
+ .difference(d._cols_ignore)
1144
+ )
1145
+ d.cols_added = (
1146
+ d.new.columns
1147
+ .difference(d.old.columns)
1148
+ .difference(d._cols_ignore)
1149
+ )
1150
+ d.cols_removed = (
1151
+ d.old.columns
1152
+ .difference(d.new.columns)
1153
+ .difference(d._cols_ignore)
1154
+ )
1155
+
1156
+ d.rows_shared = (
1157
+ d.new.index
1158
+ .intersection(d.old.index)
1159
+ )
1160
+ d.rows_added = (
1161
+ d.new.index
1162
+ .difference(d.old.index)
1163
+ )
1164
+ d.rows_removed = (
1165
+ d.old.index
1166
+ .difference(d.new.index)
1167
+ )
1168
+
1169
+ return d
1170
+
1171
+
1172
+
1173
+ def _create_meta_cols(d: Diff) -> Diff:
1174
+
1175
+ if d.mode != 'new+':
1176
+ d._metadata_col_mapping = {}
1177
+ return d
1178
+
1179
+ values = d._values
1180
+ style = d._style
1181
+
1182
+ col_mapping = {}
1183
+ cols_reorder = []
1184
+
1185
+ for col in values.columns:
1186
+ cols_reorder.append(col)
1187
+ if col != d.uid and col in d.cols_shared:
1188
+ colname_meta = ensure_unique_string(
1189
+ col + d.suffix_old,
1190
+ taken=values.columns,
1191
+ strategy=f'suffix={d.suffix_old}',
1192
+ )
1193
+ cols_reorder.append(colname_meta)
1194
+ col_mapping[col] = colname_meta
1195
+
1196
+ df_meta = values.loc[:, col_mapping.keys()].copy()
1197
+ df_meta.loc[:, :] = pd.NA
1198
+ df_meta.rename(columns=col_mapping, inplace=True)
1199
+
1200
+ data = {
1201
+ colname: ['font-style: italic;'] * len(values.index)
1202
+ for colname in col_mapping.values()
1203
+ }
1204
+ df_meta_style = pd.DataFrame(
1205
+ data,
1206
+ index=values.index,
1207
+ dtype='string',
1208
+ )
1209
+
1210
+ values = pd.concat(
1211
+ [values, df_meta],
1212
+ axis=1,
1213
+ )
1214
+ style = pd.concat(
1215
+ [style, df_meta_style],
1216
+ axis=1,
1217
+ )
1218
+
1219
+ d._metadata_col_mapping = col_mapping
1220
+ d._values = values[cols_reorder]
1221
+ d._style = style[cols_reorder]
1222
+
1223
+ return d
1224
+
1225
+
1226
+
1227
+ def _align_dtypes(d: Diff) -> Diff:
1228
+ """
1229
+ covers dtypes resulting from df.convert_dtypes():
1230
+ - string
1231
+ - boolean
1232
+ - Int64
1233
+ - Float64
1234
+ - datetime64
1235
+ """
1236
+
1237
+ for col in d.cols_shared:
1238
+ dtype_old = str(d.old[col].dtype)
1239
+ dtype_new = str(d.new[col].dtype)
1240
+
1241
+ if dtype_old == dtype_new:
1242
+ continue
1243
+
1244
+ elif dtype_old.startswith('Int') and dtype_new.startswith('Float'):
1245
+ d.old[col] = d.old[col].astype('Float64')
1246
+
1247
+ elif dtype_new.startswith('Int') and dtype_old.startswith('Float'):
1248
+ d.new[col] = d.new[col].astype('Float64')
1249
+
1250
+ else:
1251
+ d.old[col] = d.old[col].astype('object')
1252
+ d.new[col] = d.new[col].astype('object')
1253
+
1254
+ return d
1255
+
1256
+
1257
+
1258
+ def _get_val_diffs(d: Diff) -> Diff:
1259
+
1260
+ old = d.old
1261
+ new = d.new
1262
+ rows_shared = d.rows_shared
1263
+ cols_shared = d.cols_shared
1264
+
1265
+ old_isna = old.loc[rows_shared, cols_shared].isna()
1266
+ new_isna = new.loc[rows_shared, cols_shared].isna()
1267
+ new_equals_old = (
1268
+ new.loc[rows_shared, cols_shared]
1269
+ == old.loc[rows_shared, cols_shared]
1270
+ )
1271
+
1272
+ d._mask_added = (old_isna & ~new_isna)
1273
+ d._mask_removed = (new_isna & ~old_isna)
1274
+ d._mask_changed = (~new_isna & ~old_isna & ~new_equals_old)
1275
+
1276
+ d.vals_added = d._mask_added.sum()
1277
+ d.vals_removed = d._mask_removed.sum()
1278
+ d.vals_changed = d._mask_changed.sum()
1279
+
1280
+ return d
1281
+
1282
+
1283
+
1284
+ def _populate_row_col_styles(d: Diff) -> Diff:
1285
+
1286
+ if d.mode == 'mix':
1287
+ d._style.loc[:, d.cols_added] += f'background-color: {GREEN};'
1288
+ d._style.loc[d.rows_added, :] += f'background-color: {GREEN};'
1289
+ d._style.loc[:, d.cols_removed] += f'background-color: {RED};'
1290
+ d._style.loc[d.rows_removed, :] += f'background-color: {RED};'
1291
+ d._diff_col[d.rows_added] += 'row added'
1292
+ d._diff_col[d.rows_removed] += 'row removed'
1293
+
1294
+ elif d.mode == 'old':
1295
+ d._style.loc[:, d.cols_removed] += f'background-color: {RED};'
1296
+ d._style.loc[d.rows_removed, :] += f'background-color: {RED};'
1297
+ d._diff_col[d.rows_removed] += 'row removed'
1298
+
1299
+ elif d.mode in ('new', 'new+'):
1300
+ d._style.loc[:, d.cols_added] += f'background-color: {GREEN};'
1301
+ d._style.loc[d.rows_added, :] += f'background-color: {GREEN};'
1302
+ d._diff_col[d.rows_added] += 'row added'
1303
+
1304
+ return d
1305
+
1306
+
1307
+
1308
+ def _populate_val_styles(d: Diff) -> Diff:
1309
+
1310
+ blank = pd.DataFrame(
1311
+ '',
1312
+ index=d.rows_shared,
1313
+ columns=d.cols_shared,
1314
+ )
1315
+
1316
+ add = (
1317
+ blank
1318
+ .copy()
1319
+ .mask(d._mask_added, f'background-color: {GREEN_LIGHT};')
1320
+ )
1321
+ remove = (
1322
+ blank
1323
+ .copy()
1324
+ .mask(d._mask_removed, f'background-color: {RED_LIGHT};')
1325
+ )
1326
+ change = (
1327
+ blank
1328
+ .copy()
1329
+ .mask(d._mask_changed, f'background-color: {ORANGE_LIGHT};')
1330
+ )
1331
+
1332
+ d._style.loc[d.rows_shared, d.cols_shared] += add
1333
+ d._style.loc[d.rows_shared, d.cols_shared] += remove
1334
+ d._style.loc[d.rows_shared, d.cols_shared] += change
1335
+
1336
+ return d
1337
+
1338
+
1339
+
1340
+ def _populate_diff_col(d: Diff) -> Diff:
1341
+
1342
+ sum_added = d._mask_added.sum(axis=1)
1343
+ sum_removed = d._mask_removed.sum(axis=1)
1344
+ sum_changed = d._mask_changed.sum(axis=1)
1345
+
1346
+ rows_added = sum_added[sum_added > 0].index
1347
+ rows_removed = sum_removed[sum_removed > 0].index
1348
+ rows_changed = sum_changed[sum_changed > 0].index
1349
+
1350
+ removed_and_changed = rows_removed.intersection(rows_changed)
1351
+ removed_or_changed = rows_removed.union(rows_changed)
1352
+ removed_or_changed_and_added = removed_or_changed.intersection(rows_added)
1353
+
1354
+ d._diff_col[rows_added] += 'vals added: '
1355
+ d._diff_col[rows_added] += sum_added[rows_added].astype('string')
1356
+ d._diff_col[removed_or_changed_and_added] += d.linebreak
1357
+
1358
+ d._diff_col[rows_removed] += 'vals removed: '
1359
+ d._diff_col[rows_removed] += sum_removed[rows_removed].astype('string')
1360
+ d._diff_col[removed_and_changed] += d.linebreak
1361
+
1362
+ d._diff_col[rows_changed] += 'vals changed: '
1363
+ d._diff_col[rows_changed] += sum_changed[rows_changed].astype('string')
1364
+
1365
+ return d
1366
+
1367
+
1368
+
1369
+ def _populate_meta_cols(d: Diff) -> Diff:
1370
+
1371
+ if d.mode != 'new+':
1372
+ return d
1373
+
1374
+ all_modifications = (
1375
+ d._mask_added
1376
+ | d._mask_removed
1377
+ | d._mask_changed
1378
+ )
1379
+
1380
+ old_changed = (
1381
+ d.old
1382
+ .loc[d.rows_shared, d.cols_shared]
1383
+ #.convert_dtypes() results in only nullable dtypes
1384
+ .where(all_modifications, pd.NA) #type:ignore
1385
+ )
1386
+
1387
+ for col, col_meta in d._metadata_col_mapping.items():
1388
+ d._values[col_meta] = old_changed[col]
1389
+
1390
+ return d
1391
+
1392
+
1393
+
1394
+ def _add_retained_cols(d: Diff) -> Diff:
1395
+
1396
+ if d._df_retained.empty:
1397
+ return d
1398
+
1399
+ idx_shared = d._values.index.intersection(d._df_retained.index)
1400
+ df_retained = d._df_retained.loc[idx_shared, :]
1401
+ d._values = pd.concat([df_retained, d._values], axis=1)
1402
+
1403
+ return d
1404
+
1405
+
1406
+
1407
+ def _add_diff_col(d: Diff) -> Diff:
1408
+ colname = ensure_unique_string('diff', d._values.columns)
1409
+ d._diff_col.name = colname
1410
+ d._values = pd.concat([d._diff_col, d._values], axis=1)
1411
+ return d
1412
+
1413
+
1414
+
1415
+ def _add_uid_col(d: Diff) -> Diff:
1416
+
1417
+ colname_uid = ensure_unique_string(str(d.uid), d._values.columns)
1418
+ uid_col = pd.Series(
1419
+ d._values.index,
1420
+ index=d._values.index,
1421
+ name=colname_uid,
1422
+ )
1423
+ d._values = pd.concat([uid_col, d._values], axis=1)
1424
+
1425
+ index_diff = d._values.index.difference(d._style.index)
1426
+ if index_diff.empty:
1427
+ d._values.reset_index(drop=True, inplace=True)
1428
+ d._style.reset_index(drop=True, inplace=True)
1429
+ else:
1430
+ msg = (
1431
+ 'ERROR: index of values and style dfs do not match.'
1432
+ 'This should never happen. Please report this issue.'
1433
+ f'Index difference: {index_diff}'
1434
+ )
1435
+ log(msg, 'dk.diffing._add_uid_col', d.verbosity)
1436
+
1437
+ return d
1438
+
1439
+
1440
+
1441
+ def _apply_style(d: Diff) -> Diff:
1442
+
1443
+ context = 'dk.diffing._apply_style'
1444
+ if len(d._values.columns) * len(d._values.index) > 100_000:
1445
+ msg = (
1446
+ 'warning: more than 100 000 cells are being formatted.'
1447
+ 'while this might not cause performance issues for formatting,'
1448
+ 'the result might be slow to render, especially in jupyter notebooks.'
1449
+ )
1450
+ log(msg, context, d.verbosity)
1451
+
1452
+ d.result = (
1453
+ d._values
1454
+ .style
1455
+ .apply(lambda x: d._style, axis=None)
1456
+ .set_properties(white_space='normal')
1457
+ )
1458
+ msg = 'DEBUG: created df with highlighted differences'
1459
+ log(msg, context, d.verbosity)
1460
+
1461
+ return d