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/__init__.py +108 -0
- dukit/_test_utils.py +374 -0
- dukit/diffing.py +1461 -0
- dukit/excel.py +134 -0
- dukit/interfaces.py +160 -0
- dukit/os.py +280 -0
- dukit/pandas.py +563 -0
- dukit/py.typed +0 -0
- dukit/qlang/__init__.py +61 -0
- dukit/qlang/engine.py +447 -0
- dukit/qlang/symbols.py +6867 -0
- dukit/typing.py +683 -0
- dukit/utils.py +288 -0
- dukit-0.1.0.dist-info/METADATA +113 -0
- dukit-0.1.0.dist-info/RECORD +16 -0
- dukit-0.1.0.dist-info/WHEEL +4 -0
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
|