Datavision / backend /mcp /data_cleaner.py
DataVision CI/CD Bot
release: clean production build for HuggingFace Space
09801ca
Raw
History Blame Contribute Delete
12.3 kB
# 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}