Coverage for /var/devmt/py/utils4_1.8.0/utils4/dfdiff.py: 100%
65 statements
« prev ^ index » next coverage.py v7.6.9, created at 2025-08-26 21:39 +0100
« prev ^ index » next coverage.py v7.6.9, created at 2025-08-26 21:39 +0100
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3"""
4:Purpose: This module provides DataFrame differencing logic.
6 The caller creates an instance which accepts the two DataFrames
7 to be compared as the arguments. When the
8 :meth:`~DataFrameDiff.diff` method is called, a list of columns
9 containing value mismatches is compiled. Then, the list of column
10 mismatches is iterated with each value in the column being
11 compared. All value mismatches are reported to the terminal.
13:Developer: J Berendt
14:Email: support@s3dev.uk
16:Note: It's worth noting that current functionality **does not
17 check data types**, unlike the pandas ``pd.DataFrame.equals()``
18 method. This functionality may be added in a future release.
20:Example:
22 Short example for differencing two DataFrames::
24 >>> from utils4 import dfdiff
26 >>> d = dfdiff.DataFrameDiff(df_source, df_test)
27 >>> d.diff()
29"""
30# pylint: disable=wrong-import-order
32import pandas as pd
33from itertools import zip_longest
34try:
35 from .user_interface import ui
36except ImportError: # pragma: nocover
37 from user_interface import ui
40class _Messages:
41 """This private class handles the messaging for DataFrame differencing."""
43 _FMT = '{:<10}\t{:<10}\t{:<25}\t{:<25}'
45 @staticmethod
46 def column_mismatches(columns: list):
47 """List columns with mismatches.
49 Args:
50 columns (list): A list of columns containing mismatches.
52 """
53 # pylint: disable=consider-using-f-string
54 ui.print_('\nColumn mismatches:', fore='cyan', style='normal')
55 print(*map('- {}'.format, columns), sep='\n')
57 @staticmethod
58 def column_mismatches_none():
59 """Print message for no column mismatches."""
60 ui.print_('\nNo mismatches for this set.', fore='green')
62 def data_mismatches(self, column: str, mismatches: list):
63 """Print the data mismatches.
65 Args:
66 column (str): Name of the column being analysed.
67 mismatches (list): A list of tuples containing data mismatches,
68 as::
70 [(0, 0, 1, 2), (1, 1, 3, 4)]
72 """
73 ui.print_(f'Data mismatches for column: {column}', fore='yellow')
74 print(self._FMT.format('SrcRow', 'TstRow', 'SrcValue', 'TstValue'))
75 print('-'*92)
76 print(*(self._FMT.format(*m) for m in mismatches), sep='\n')
77 print()
79 @staticmethod
80 def data_mismatches_none(column: str):
81 """Print message for no data mismatches.
83 Args:
84 column (str): Name of the column being analysed.
86 """
87 ui.print_(f'\nNo data mismatches for {column}', fore='green')
90class DataFrameDiff:
91 """Test and report differences in two pandas DataFrames.
93 Args:
94 df_source (pd.DataFrame): DataFrame containing **source** data.
95 This dataset holds the **expected** results.
96 df_test (pd.DataFrame): DataFrame containing the **test** data.
97 This dataset is compared against the 'expected' dataset.
99 """
101 def __init__(self, df_source: pd.DataFrame, df_test: pd.DataFrame):
102 """DataFrame difference class initialiser."""
103 self._df_s = df_source
104 self._df_t = df_test
105 self._col_mismatches = []
106 self._msg = _Messages()
108 def diff(self):
109 """Compare DataFrames and report the differences."""
110 self._get_mismatches()
111 self._report()
113 def _get_mismatches(self):
114 """Build a list of columns with mismatches."""
115 # Add column to list if it contains a mismatch.
116 mis = [col for col in self._df_s.columns
117 if not self._df_t[col].equals(self._df_s[col])]
118 if mis:
119 self._msg.column_mismatches(columns=self._col_mismatches)
120 else:
121 self._msg.column_mismatches_none()
122 self._col_mismatches = mis
124 def _report(self) -> None:
125 """Compare values in mismatched columns and report."""
126 for col in self._col_mismatches:
127 mismatches = []
128 # Zip source and test datasets.
129 for (idx1, row1), (idx2, row2) in zip_longest(self._df_s.iterrows(),
130 self._df_t.iterrows(),
131 fillvalue=(None, None)):
132 # Catch if a row exists in one dataset and not the other.
133 if any([row1 is None, row2 is None]):
134 idx1 = idx1 if idx1 is not None else idx2
135 idx2 = idx2 if idx2 is not None else idx1
136 val1 = str(row1[col]) if row1 is not None else 'no value (source)'
137 val2 = str(row2[col]) if row2 is not None else 'no value (test)'
138 # Convert datetimes to string for compare.
139 elif isinstance(row2[col], pd.Timestamp):
140 val1 = str(row1[col])
141 val2 = str(row2[col])
142 # Enable compare of nan types.
143 elif any([pd.isna(row1[col]), pd.isna(row2[col])]):
144 # Convert mismatched nan/NaT types to 'NaT' string.
145 if all([pd.isna(row1[col]), row2[col] is pd.NaT]):
146 val1 = 'NaT'
147 val2 = 'NaT'
148 else:
149 val1 = str(row1[col])
150 val2 = str(row2[col])
151 # Reformat floats to align.
152 elif any([isinstance(row1[col], float), isinstance(row2[col], float)]):
153 val1 = round(float(row1[col]), 5)
154 val2 = round(float(row2[col]), 5)
155 else:
156 # Convert to string for each compare.
157 val1 = str(row1[col])
158 val2 = str(row2[col])
159 # Do the compare.
160 if val1 != val2:
161 # Add any mismatches to a list for reporting.
162 mismatches.append((idx1, idx2, val1, val2))
163 if mismatches:
164 self._msg.data_mismatches(column=col, mismatches=mismatches)
165 else:
166 self._msg.data_mismatches_none(column=col)