# MCP Data Cleaner Module """ Data cleaning and preprocessing tools for MCP integration. Features: - Table cleaning and normalization - Number formatting (currency, percentages) - Date normalization - Deduplication - Missing value handling """ from dataclasses import dataclass from typing import List, Dict, Optional, Any, Union import re from datetime import datetime @dataclass class CleaningOptions: """Options for data cleaning""" remove_empty_rows: bool = True remove_empty_cols: bool = True strip_whitespace: bool = True normalize_case: bool = False handle_missing: str = "keep" # "keep", "remove", "fill" fill_value: Any = None def clean_table( data: Union[Dict, List[Dict], Any], options: Optional[CleaningOptions] = None ) -> Dict: """ Clean and normalize tabular data. Args: data: DataFrame dict, list of dicts, or pandas DataFrame options: Cleaning configuration Returns: Cleaned data as dict """ options = options or CleaningOptions() try: import pandas as pd # Convert to DataFrame if isinstance(data, pd.DataFrame): df = data.copy() elif isinstance(data, dict): df = pd.DataFrame(data) elif isinstance(data, list): df = pd.DataFrame(data) else: return {"error": "Unsupported data type", "data": None} # Strip whitespace from string columns if options.strip_whitespace: for col in df.select_dtypes(include=['object']).columns: df[col] = df[col].astype(str).str.strip() # Remove empty rows if options.remove_empty_rows: df = df.dropna(how='all') # Remove empty columns if options.remove_empty_cols: df = df.dropna(axis=1, how='all') # Handle missing values if options.handle_missing == "remove": df = df.dropna() elif options.handle_missing == "fill" and options.fill_value is not None: df = df.fillna(options.fill_value) # Normalize case if options.normalize_case: for col in df.select_dtypes(include=['object']).columns: df[col] = df[col].str.lower() return { "success": True, "data": df.to_dict(orient='records'), "rows": len(df), "columns": list(df.columns) } except Exception as e: return {"success": False, "error": str(e), "data": None} def format_numbers( data: Any, columns: List[str], format_type: str = "decimal", decimal_places: int = 2, currency_symbol: str = "₹" ) -> Dict: """ Format and standardize numeric columns. Args: data: DataFrame or dict columns: Columns to format format_type: "decimal", "currency", "percentage", "integer" decimal_places: Number of decimal places currency_symbol: Symbol for currency formatting Returns: Data with formatted numbers """ try: import pandas as pd if isinstance(data, pd.DataFrame): df = data.copy() else: df = pd.DataFrame(data) for col in columns: if col not in df.columns: continue # Clean and convert to numeric df[col] = _clean_numeric_column(df[col]) if format_type == "currency": df[col] = df[col].apply( lambda x: f"{currency_symbol}{x:,.{decimal_places}f}" if pd.notna(x) else "" ) elif format_type == "percentage": df[col] = df[col].apply( lambda x: f"{x:.{decimal_places}f}%" if pd.notna(x) else "" ) elif format_type == "integer": df[col] = df[col].apply( lambda x: f"{int(x):,}" if pd.notna(x) else "" ) else: # decimal df[col] = df[col].apply( lambda x: f"{x:,.{decimal_places}f}" if pd.notna(x) else "" ) return { "success": True, "data": df.to_dict(orient='records'), "formatted_columns": columns } except Exception as e: return {"success": False, "error": str(e), "data": None} def _clean_numeric_column(series) -> Any: """Clean a column to numeric values""" import pandas as pd def clean_value(val): if pd.isna(val): return None # Convert to string and clean val_str = str(val) # Remove currency symbols and commas cleaned = re.sub(r'[₹$€£,\s]', '', val_str) # Handle percentage if '%' in val_str: cleaned = cleaned.replace('%', '') try: return float(cleaned) except: return None try: return float(cleaned) except: return None return series.apply(clean_value) def normalize_dates( data: Any, date_columns: List[str], output_format: str = "%Y-%m-%d" ) -> Dict: """ Normalize date formats across data. Args: data: DataFrame or dict date_columns: Columns containing dates output_format: Desired output format Returns: Data with normalized dates """ try: import pandas as pd if isinstance(data, pd.DataFrame): df = data.copy() else: df = pd.DataFrame(data) # Common date formats to try date_formats = [ "%Y-%m-%d", "%d-%m-%Y", "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d", "%d %b %Y", "%d %B %Y", "%B %d, %Y", "%b %d, %Y", ] for col in date_columns: if col not in df.columns: continue df[col] = df[col].apply( lambda x: _parse_and_format_date(x, date_formats, output_format) ) return { "success": True, "data": df.to_dict(orient='records'), "normalized_columns": date_columns, "output_format": output_format } except Exception as e: return {"success": False, "error": str(e), "data": None} def _parse_and_format_date( value: Any, input_formats: List[str], output_format: str ) -> str: """Parse date with multiple formats and output in standard format""" import pandas as pd if pd.isna(value) or value == '': return '' value_str = str(value).strip() # Try pandas parsing first (handles many formats) try: parsed = pd.to_datetime(value_str) return parsed.strftime(output_format) except: pass # Try specific formats for fmt in input_formats: try: parsed = datetime.strptime(value_str, fmt) return parsed.strftime(output_format) except: continue # Return original if parsing fails return value_str def deduplicate_rows( data: Any, subset: Optional[List[str]] = None, keep: str = "first" ) -> Dict: """ Remove duplicate rows from data. Args: data: DataFrame or dict subset: Columns to consider for duplicates (None = all) keep: "first", "last", or False (remove all duplicates) Returns: Deduplicated data """ try: import pandas as pd if isinstance(data, pd.DataFrame): df = data.copy() else: df = pd.DataFrame(data) original_count = len(df) df = df.drop_duplicates(subset=subset, keep=keep) removed_count = original_count - len(df) return { "success": True, "data": df.to_dict(orient='records'), "original_rows": original_count, "remaining_rows": len(df), "removed_duplicates": removed_count } except Exception as e: return {"success": False, "error": str(e), "data": None} def fill_missing_values( data: Any, strategy: str = "mean", columns: Optional[List[str]] = None, fill_value: Any = None ) -> Dict: """ Fill missing values in data. Args: data: DataFrame or dict strategy: "mean", "median", "mode", "forward", "backward", "value" columns: Columns to fill (None = all) fill_value: Value to use when strategy is "value" Returns: Data with filled values """ try: import pandas as pd if isinstance(data, pd.DataFrame): df = data.copy() else: df = pd.DataFrame(data) cols = columns or df.columns.tolist() for col in cols: if col not in df.columns: continue if strategy == "mean": if df[col].dtype in ['int64', 'float64']: df[col] = df[col].fillna(df[col].mean()) elif strategy == "median": if df[col].dtype in ['int64', 'float64']: df[col] = df[col].fillna(df[col].median()) elif strategy == "mode": mode_val = df[col].mode() if len(mode_val) > 0: df[col] = df[col].fillna(mode_val[0]) elif strategy == "forward": df[col] = df[col].fillna(method='ffill') elif strategy == "backward": df[col] = df[col].fillna(method='bfill') elif strategy == "value" and fill_value is not None: df[col] = df[col].fillna(fill_value) return { "success": True, "data": df.to_dict(orient='records'), "strategy": strategy, "processed_columns": cols } except Exception as e: return {"success": False, "error": str(e), "data": None} def detect_anomalies( data: Any, columns: List[str], method: str = "zscore", threshold: float = 3.0 ) -> Dict: """ Detect anomalies in numeric columns. Args: data: DataFrame or dict columns: Numeric columns to check method: "zscore", "iqr" threshold: Z-score threshold or IQR multiplier Returns: Data with anomaly flags """ try: import pandas as pd import numpy as np if isinstance(data, pd.DataFrame): df = data.copy() else: df = pd.DataFrame(data) anomalies = {} for col in columns: if col not in df.columns or df[col].dtype not in ['int64', 'float64']: continue if method == "zscore": mean = df[col].mean() std = df[col].std() if std > 0: z_scores = np.abs((df[col] - mean) / std) anomaly_mask = z_scores > threshold else: anomaly_mask = pd.Series([False] * len(df)) else: # IQR Q1 = df[col].quantile(0.25) Q3 = df[col].quantile(0.75) IQR = Q3 - Q1 lower = Q1 - threshold * IQR upper = Q3 + threshold * IQR anomaly_mask = (df[col] < lower) | (df[col] > upper) anomalies[col] = { "count": int(anomaly_mask.sum()), "indices": list(df[anomaly_mask].index) } return { "success": True, "anomalies": anomalies, "method": method, "threshold": threshold } except Exception as e: return {"success": False, "error": str(e), "anomalies": None}