Skip to content

Commit d596272

Browse files
author
miranov25
committed
feat: Add flexible missing column handling to compress_columns
Add on_missing and return_summary parameters to compress_columns method for more flexible handling of missing columns during compression workflows. Changes: - Add on_missing parameter with three modes: * 'warn' (default): Skip missing columns with warning * 'error': Raise KeyError if any column missing (strict mode) * 'ignore': Skip missing columns silently - Add return_summary parameter to return compression results: * Returns dict with 'compressed' and 'skipped' column lists * Maintains backward compatibility (default returns self) - Implement smart column availability checking: * Checks DataFrame columns, aliases, and compression_info * Handles compressed columns that become aliases * Preserves state validation for already-tracked columns * Filtering now applies uniformly across all modes (including selective) - Update test_selective_mode_validates_column_exists: * Now uses on_missing='error' for strict validation * Changed expected exception from ValueError to KeyError * Reflects new filtering behavior - Add comprehensive test suite (TestCompressionOnMissing): * 9 new tests covering all on_missing modes * Tests for return_summary functionality * Tests for method chaining and backward compatibility * Tests for edge cases (all missing, partial missing) This enables progressive compression workflows where some columns may not exist yet, while maintaining strict validation when needed for production use cases. Breaking change: Default behavior now warns and skips missing columns instead of raising an error. Use on_missing='error' for strict validation. All tests passing (70/70).
1 parent 62c73fa commit d596272

2 files changed

Lines changed: 217 additions & 58 deletions

File tree

UTILS/dfextensions/AliasDataFrame/AliasDataFrame.py

Lines changed: 40 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -835,49 +835,46 @@ def compress_columns(self, compression_spec=None, columns=None, suffix='_c', dro
835835
)
836836

837837
# === NEW: Filter columns based on on_missing parameter ===
838-
# Special handling for selective mode to preserve original validation behavior
839-
if schema_mode != 'selective':
840-
import warnings
841-
842-
# Check which columns exist in DataFrame, aliases, OR are already tracked
843-
existing_in_df = set(self.df.columns)
844-
existing_in_aliases = set(self.aliases.keys())
845-
tracked_in_schema = set(self.compression_info.keys()) - {'__meta__'}
846-
847-
# A column is "available" if:
848-
# 1. It exists physically in the DataFrame, OR
849-
# 2. It exists as an alias (compressed columns become aliases), OR
850-
# 3. It's already tracked in compression_info (for state validation)
851-
available_cols = [col for col in cols_to_process
852-
if col in existing_in_df or
853-
col in existing_in_aliases or
854-
col in tracked_in_schema]
855-
856-
# A column is "missing" only if it's nowhere: not in df, not an alias, not tracked
857-
missing_cols = [col for col in cols_to_process
858-
if col not in existing_in_df and
859-
col not in existing_in_aliases and
860-
col not in tracked_in_schema]
861-
862-
# Handle missing columns according to on_missing mode
863-
if missing_cols:
864-
if on_missing == 'error':
865-
raise KeyError(
866-
f"Missing columns: {missing_cols}\n"
867-
f"Available in DataFrame: {list(existing_in_df)[:20]}...\n"
868-
f"Available as aliases: {list(existing_in_aliases)[:20]}..."
869-
)
870-
elif on_missing == 'warn':
871-
warnings.warn(
872-
f"Skipping missing columns: {missing_cols}\n"
873-
f"Hint: Use columns= to restrict, or on_missing='error' for strict mode."
874-
)
875-
# else: on_missing == 'ignore', do nothing
838+
import warnings
839+
840+
# Check which columns exist in DataFrame, aliases, OR are already tracked
841+
existing_in_df = set(self.df.columns)
842+
existing_in_aliases = set(self.aliases.keys())
843+
tracked_in_schema = set(self.compression_info.keys()) - {'__meta__'}
844+
845+
# A column is "available" if:
846+
# 1. It exists physically in the DataFrame, OR
847+
# 2. It exists as an alias (compressed columns become aliases), OR
848+
# 3. It's already tracked in compression_info (for state validation)
849+
available_cols = [col for col in cols_to_process
850+
if col in existing_in_df or
851+
col in existing_in_aliases or
852+
col in tracked_in_schema]
853+
854+
# A column is "missing" only if it's nowhere: not in df, not an alias, not tracked
855+
missing_cols = [col for col in cols_to_process
856+
if col not in existing_in_df and
857+
col not in existing_in_aliases and
858+
col not in tracked_in_schema]
859+
860+
# Handle missing columns according to on_missing mode
861+
if missing_cols:
862+
if on_missing == 'error':
863+
raise KeyError(
864+
f"Missing columns: {missing_cols}\n"
865+
f"Available in DataFrame: {list(existing_in_df)[:20]}...\n"
866+
f"Available as aliases: {list(existing_in_aliases)[:20]}..."
867+
)
868+
elif on_missing == 'warn':
869+
warnings.warn(
870+
f"Skipping missing columns: {missing_cols}\n"
871+
f"Hint: Use columns= to restrict, or on_missing='error' for strict mode."
872+
)
873+
# else: on_missing == 'ignore', do nothing
876874

877-
# Update cols_to_process to only include available columns
878-
cols_to_process = available_cols
879-
# else: In selective mode, don't filter - let existing validation handle it
880-
# === END NEW CODE ===
875+
# Update cols_to_process to only include available columns
876+
cols_to_process = available_cols
877+
# === END NEW CODE ===
881878

882879
for orig_col in cols_to_process:
883880
# Get config (from spec or existing schema)
@@ -901,16 +898,7 @@ def compress_columns(self, compression_spec=None, columns=None, suffix='_c', dro
901898
)
902899
compressed_col = f"{orig_col}{suffix}"
903900

904-
# For selective mode, validate column exists and handle schema updates
905-
if schema_mode == 'selective':
906-
# Validate column exists in DataFrame or aliases
907-
if orig_col not in self.df.columns and orig_col not in self.aliases:
908-
available = list(self.df.columns)[:10]
909-
raise ValueError(
910-
f"Column '{orig_col}' not found in DataFrame or aliases. "
911-
f"Cannot compress non-existent column.\n"
912-
f"Available columns (first 10): {available}..."
913-
)
901+
# For selective mode, nothing to be done
914902

915903
# Check current state and validate transitions
916904
current_state = self.get_compression_state(orig_col)

UTILS/dfextensions/AliasDataFrame/AliasDataFrameTest.py renamed to UTILS/dfextensions/AliasDataFrame/test_alias_dataframe.py

Lines changed: 177 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,7 +1091,7 @@ def test_selective_mode_errors_on_schema_change_when_compressed(self):
10911091
self.assertIn('decompress first', str(cm.exception).lower())
10921092

10931093
def test_selective_mode_validates_column_exists(self):
1094-
"""Test that selective mode validates column exists in DataFrame"""
1094+
"""Test that selective mode with on_missing='error' validates column exists"""
10951095
spec = {
10961096
'nonexistent': {
10971097
'compress': 'round(nonexistent*10)',
@@ -1101,12 +1101,11 @@ def test_selective_mode_validates_column_exists(self):
11011101
}
11021102
}
11031103

1104-
with self.assertRaises(ValueError) as cm:
1105-
self.adf.compress_columns(spec, columns=['nonexistent'])
1106-
1107-
self.assertIn('not found in DataFrame', str(cm.exception))
1108-
self.assertIn('nonexistent', str(cm.exception))
1104+
# Use on_missing='error' for strict validation
1105+
with self.assertRaises(KeyError) as cm:
1106+
self.adf.compress_columns(spec, columns=['nonexistent'], on_missing='error')
11091107

1108+
self.assertIn("Missing columns", str(cm.exception))
11101109
def test_selective_mode_validates_columns_in_spec(self):
11111110
"""Test that selective mode validates requested columns are in spec"""
11121111
with self.assertRaises(ValueError) as cm:
@@ -1212,5 +1211,177 @@ def test_pattern1_pattern2_mixing(self):
12121211
self.assertEqual(self.adf.compression_info['dz']['compress_expr'], self.spec['dz']['compress'])
12131212

12141213

1214+
class TestCompressionOnMissing(unittest.TestCase):
1215+
"""Test on_missing and return_summary parameters"""
1216+
1217+
def setUp(self):
1218+
"""Create test DataFrame and compression spec"""
1219+
self.df = pd.DataFrame({
1220+
'dy': np.random.randn(10),
1221+
'dz': np.random.randn(10),
1222+
'y': np.random.randn(10) * 50,
1223+
})
1224+
1225+
# Spec with some columns that won't exist in df
1226+
self.spec = {
1227+
'dy': {
1228+
'compress': 'round(asinh(dy)*40)',
1229+
'decompress': 'sinh(dy_c/40.)',
1230+
'compressed_dtype': np.int16,
1231+
'decompressed_dtype': np.float16
1232+
},
1233+
'dz': {
1234+
'compress': 'round(asinh(dz)*40)',
1235+
'decompress': 'sinh(dz_c/40.)',
1236+
'compressed_dtype': np.int16,
1237+
'decompressed_dtype': np.float16
1238+
},
1239+
'y': {
1240+
'compress': 'round(y*(0x7fff/50.))',
1241+
'decompress': 'y_c*(50.0/(0x7fff))',
1242+
'compressed_dtype': np.int16,
1243+
'decompressed_dtype': np.float32
1244+
},
1245+
'dyC0': { # This column doesn't exist in df
1246+
'compress': 'round(asinh(dyC0)*100)',
1247+
'decompress': 'sinh(dyC0_c/100.)',
1248+
'compressed_dtype': np.int16,
1249+
'decompressed_dtype': np.float16
1250+
}
1251+
}
1252+
1253+
def test_default_warn_mode(self):
1254+
"""Test default on_missing='warn' behavior"""
1255+
adf = AliasDataFrame(self.df)
1256+
1257+
with self.assertWarns(UserWarning) as cm:
1258+
result = adf.compress_columns(self.spec, return_summary=True)
1259+
1260+
# Check warning message
1261+
self.assertIn("Skipping missing columns", str(cm.warning))
1262+
self.assertIn("dyC0", str(cm.warning))
1263+
1264+
# Check summary
1265+
self.assertEqual(set(result['compressed']), {'dy', 'dz', 'y'})
1266+
self.assertEqual(result['skipped'], ['dyC0'])
1267+
1268+
# Verify compression worked
1269+
self.assertIn('dy_c', adf.df.columns)
1270+
self.assertIn('dz_c', adf.df.columns)
1271+
self.assertIn('y_c', adf.df.columns)
1272+
self.assertNotIn('dyC0_c', adf.df.columns)
1273+
1274+
def test_strict_error_mode(self):
1275+
"""Test on_missing='error' raises KeyError"""
1276+
adf = AliasDataFrame(self.df)
1277+
1278+
with self.assertRaises(KeyError) as cm:
1279+
adf.compress_columns(self.spec, on_missing='error')
1280+
1281+
# Check error message
1282+
self.assertIn("Missing columns", str(cm.exception))
1283+
self.assertIn("dyC0", str(cm.exception))
1284+
1285+
def test_silent_ignore_mode(self):
1286+
"""Test on_missing='ignore' produces no warnings"""
1287+
adf = AliasDataFrame(self.df)
1288+
1289+
# Use warnings filter to catch any warnings
1290+
import warnings
1291+
with warnings.catch_warnings(record=True) as w:
1292+
warnings.simplefilter("always")
1293+
result = adf.compress_columns(self.spec, on_missing='ignore', return_summary=True)
1294+
1295+
# No warnings should be raised
1296+
self.assertEqual(len(w), 0)
1297+
1298+
# But compression should still work
1299+
self.assertEqual(set(result['compressed']), {'dy', 'dz', 'y'})
1300+
self.assertEqual(result['skipped'], ['dyC0'])
1301+
1302+
def test_explicit_columns_subset(self):
1303+
"""Test compression with explicit columns parameter"""
1304+
adf = AliasDataFrame(self.df)
1305+
1306+
# Only compress dy and dz
1307+
result = adf.compress_columns(
1308+
self.spec,
1309+
columns=['dy', 'dz'],
1310+
return_summary=True
1311+
)
1312+
1313+
self.assertEqual(set(result['compressed']), {'dy', 'dz'})
1314+
self.assertEqual(result['skipped'], []) # All requested columns exist
1315+
1316+
# Verify only requested columns were compressed
1317+
self.assertIn('dy_c', adf.df.columns)
1318+
self.assertIn('dz_c', adf.df.columns)
1319+
self.assertNotIn('y_c', adf.df.columns)
1320+
1321+
def test_return_summary_default_false(self):
1322+
"""Test that return_summary=False returns self (backward compatible)"""
1323+
adf = AliasDataFrame(self.df)
1324+
1325+
# Default should return self
1326+
result = adf.compress_columns({'dy': self.spec['dy']})
1327+
self.assertIs(result, adf)
1328+
1329+
# Explicit False should also return self
1330+
result = adf.compress_columns(
1331+
{'dz': self.spec['dz']},
1332+
return_summary=False
1333+
)
1334+
self.assertIs(result, adf)
1335+
1336+
def test_all_columns_missing_warn(self):
1337+
"""Test behavior when all columns are missing"""
1338+
adf = AliasDataFrame(pd.DataFrame({'x': [1, 2, 3]}))
1339+
1340+
with self.assertWarns(UserWarning) as cm:
1341+
result = adf.compress_columns(self.spec, return_summary=True)
1342+
1343+
self.assertIn("Skipping missing columns", str(cm.warning))
1344+
self.assertEqual(result['compressed'], [])
1345+
self.assertEqual(set(result['skipped']), {'dy', 'dz', 'y', 'dyC0'})
1346+
1347+
def test_all_columns_missing_error(self):
1348+
"""Test error mode when all columns are missing"""
1349+
adf = AliasDataFrame(pd.DataFrame({'x': [1, 2, 3]}))
1350+
1351+
with self.assertRaises(KeyError) as cm:
1352+
adf.compress_columns(self.spec, on_missing='error')
1353+
1354+
self.assertIn("Missing columns", str(cm.exception))
1355+
1356+
def test_partial_missing_with_columns_param(self):
1357+
"""Test warning when some explicitly requested columns are missing"""
1358+
df = pd.DataFrame({'dy': np.random.randn(10)})
1359+
adf = AliasDataFrame(df)
1360+
1361+
with self.assertWarns(UserWarning) as cm:
1362+
result = adf.compress_columns(
1363+
self.spec,
1364+
columns=['dy', 'dyC0'], # dyC0 doesn't exist
1365+
return_summary=True
1366+
)
1367+
1368+
self.assertEqual(result['compressed'], ['dy'])
1369+
self.assertEqual(result['skipped'], ['dyC0'])
1370+
1371+
def test_method_chaining_still_works(self):
1372+
"""Test that method chaining still works with default parameters"""
1373+
adf = AliasDataFrame(self.df)
1374+
1375+
# Should be able to chain
1376+
result = (adf
1377+
.compress_columns({'dy': self.spec['dy']})
1378+
.compress_columns({'dz': self.spec['dz']}))
1379+
1380+
self.assertIs(result, adf)
1381+
self.assertIn('dy_c', adf.df.columns)
1382+
self.assertIn('dz_c', adf.df.columns)
1383+
1384+
1385+
12151386
if __name__ == "__main__":
12161387
unittest.main()

0 commit comments

Comments
 (0)