← Files Investment BankingARCHIVED FILE
skills/investment-banking/internal-support/excel-data-cleaner/scripts/clean_tabular_data.py
29.9 KB · Oct 2, 2026 · 00:27 UTC
#!/usr/bin/env python3
"""Conservative first-pass cleaner for messy CSV/XLSX data.
Creates an audit-ready workbook with cleaned data, raw source, data dictionary,
quality checks, and assumptions/audit sheets. This is a helper for the skill's
analyst workflow, not a replacement for human/AI review of ambiguous business rules.
"""
from __future__ import annotations
import argparse
import math
import re
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
import pandas as pd
PLUGIN_ROOT = Path(__file__).resolve().parents[5]
if str(PLUGIN_ROOT) not in sys.path:
sys.path.insert(0, str(PLUGIN_ROOT))
from shared.artifacts import write_artifact_manifest # noqa: E402
BLANK_TOKENS = {"", "-", "--", "n/a", "na", "null", "none", "not available", "#n/a", "nan"}
TOTAL_RE = re.compile(r"\b(grand\s+total|subtotal|sub-total|total)\b", re.I)
ID_HINT_RE = re.compile(
r"\b(id|code|sku|zip|postal|phone|cusip|isin|sedol|ticker|account\s*#|invoice\s*#|po\s*#|employee)\b",
re.I,
)
CURRENCY_RE = re.compile(r"[$€£¥]|\b(usd|eur|gbp|jpy|cad|aud|chf|cny)\b", re.I)
PERCENT_RE = re.compile(r"%|\bbps\b|\bbasis\s+points\b", re.I)
NUMERIC_RE = re.compile(
r"^\(?[+-]?\s*[$€£¥]?\s*\d{1,3}(?:,\d{3})*(?:\.\d+)?\s*\)?$|^\(?[+-]?\s*[$€£¥]?\s*\d+(?:\.\d+)?\s*\)?$"
)
DOMAIN_REQUIRED_HINTS: dict[str, list[str]] = {
"finance": ["amount", "account", "period", "scenario", "department", "currency"],
"capiq_factset_export": ["ticker", "company", "period", "revenue", "ebitda", "currency"],
"cim_table": ["metric", "period", "revenue", "ebitda", "adjustment", "source"],
"debt_schedule": ["facility", "maturity", "rate", "spread", "debt", "amortization"],
"process_tracker": ["buyer", "investor", "status", "owner", "nda", "ioi", "loi"],
"qoe_schedule": ["adjustment", "ebitda", "quality", "source", "period", "basis"],
"investing": ["ticker", "security", "date", "price", "return", "currency"],
"operations": ["id", "owner", "status", "priority", "created", "resolved"],
"sales": ["account", "opportunity", "stage", "owner", "close", "amount"],
"product": ["user", "event", "timestamp", "session", "platform"],
"hr": ["employee", "manager", "status", "start", "location"],
"procurement": ["vendor", "invoice", "po", "contract", "renewal", "amount", "currency"],
}
OPENPYXL_INSTALL_COMMAND = "python3 -m pip install -r scripts/requirements.txt"
OPENPYXL_REVIEW_FALLBACK = (
"Manual-review fallback: export workbook sheets to CSV or inspect them in Excel/Sheets, "
"then preserve the raw source, data dictionary, quality checks, and assumptions/audit notes while cleaning manually."
)
_OPENPYXL_MODULES: tuple[Any, Any, Any, Any] | None = None
class DependencyPreflightError(RuntimeError):
"""Raised when an optional workbook dependency is missing."""
def openpyxl_dependency_message(action: str) -> str:
return (
f"openpyxl is required to {action}.\n"
f"Install locally from this skill directory with: {OPENPYXL_INSTALL_COMMAND}\n"
f"{OPENPYXL_REVIEW_FALLBACK}"
)
def load_openpyxl_modules() -> tuple[Any, Any, Any, Any]:
global _OPENPYXL_MODULES
if _OPENPYXL_MODULES is not None:
return _OPENPYXL_MODULES
try:
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.table import Table, TableStyleInfo
except ModuleNotFoundError as exc:
if exc.name == "openpyxl":
raise DependencyPreflightError(
openpyxl_dependency_message("write and format the cleaned workbook")
) from exc
raise
_OPENPYXL_MODULES = (load_workbook, Table, TableStyleInfo, get_column_letter)
return _OPENPYXL_MODULES
def norm_cell(value: Any) -> str:
if value is None:
return ""
if isinstance(value, float) and math.isnan(value):
return ""
return re.sub(r"\s+", " ", str(value).replace("\u00a0", " ").strip())
def is_blank(value: Any) -> bool:
return norm_cell(value).lower() in BLANK_TOKENS
def clean_text_value(value: Any) -> Any:
text = norm_cell(value)
if text.lower() in BLANK_TOKENS:
return None
return text
def map_dataframe(df: pd.DataFrame, func):
mapper = getattr(df, "map", None)
if mapper is not None:
return mapper(func)
return df.applymap(func)
def snake_case(text: str) -> str:
text = norm_cell(text).lower()
text = re.sub(r"[^a-z0-9]+", "_", text)
text = re.sub(r"_+", "_", text).strip("_")
return text or "column"
def display_header(text: str) -> str:
text = norm_cell(text)
text = re.sub(r"[_\-]+", " ", text)
text = re.sub(r"\s+", " ", text).strip()
if not text:
return "Column"
keep_upper = {
"id",
"sku",
"arr",
"mrr",
"acv",
"tcv",
"cogs",
"ebitda",
"api",
"sla",
"po",
"gl",
"fy",
}
words = []
for word in text.split(" "):
stripped = re.sub(r"[^A-Za-z0-9]", "", word).lower()
if stripped in keep_upper:
words.append(stripped.upper())
else:
words.append(word[:1].upper() + word[1:].lower() if word else word)
return " ".join(words)
def unique_headers(
raw_headers: Iterable[Any], style: str
) -> tuple[list[str], list[dict[str, Any]]]:
seen: dict[str, int] = defaultdict(int)
headers: list[str] = []
notes: list[dict[str, Any]] = []
for idx, raw in enumerate(raw_headers, start=1):
original = norm_cell(raw)
if not original:
original = f"column_{idx}"
notes.append({"issue": "blank_header", "column_index": idx, "cleaned": original})
base = snake_case(original) if style == "snake" else display_header(original)
key = base.lower()
seen[key] += 1
cleaned = (
base
if seen[key] == 1
else f"{base}_{seen[key]}"
if style == "snake"
else f"{base} {seen[key]}"
)
if seen[key] > 1:
notes.append(
{
"issue": "duplicate_header",
"column_index": idx,
"original": original,
"cleaned": cleaned,
}
)
headers.append(cleaned)
return headers, notes
def cell_has_letters(text: str) -> bool:
return bool(re.search(r"[A-Za-z]", text))
def looks_numeric(text: str) -> bool:
if not text:
return False
return bool(NUMERIC_RE.match(text.replace("%", "")))
def header_score(df: pd.DataFrame, row_idx: int) -> float:
row = [norm_cell(v) for v in df.iloc[row_idx].tolist()]
nonempty = [v for v in row if v]
if len(nonempty) < 2:
return -1000.0
unique_ratio = len({v.lower() for v in nonempty}) / max(len(nonempty), 1)
text_ratio = sum(cell_has_letters(v) for v in nonempty) / max(len(nonempty), 1)
numeric_ratio = sum(looks_numeric(v) for v in nonempty) / max(len(nonempty), 1)
next_ratio = 0.0
if row_idx + 1 < len(df):
next_vals = [norm_cell(v) for v in df.iloc[row_idx + 1].tolist()]
next_ratio = sum(bool(v) for v in next_vals) / max(len(next_vals), 1)
total_penalty = 2.0 if any(TOTAL_RE.search(v) for v in nonempty) else 0.0
return (
(len(nonempty) * 0.15)
+ (unique_ratio * 2.0)
+ (text_ratio * 3.0)
+ (next_ratio * 1.5)
- (numeric_ratio * 2.0)
- total_penalty
)
def detect_header_row(df: pd.DataFrame) -> int:
if df.empty:
return 0
scores = [(header_score(df, i), i) for i in range(min(len(df), 30))]
scores.sort(reverse=True)
return scores[0][1]
def read_input(path: Path, sheet: str | None = None) -> dict[str, pd.DataFrame]:
ext = path.suffix.lower()
if ext in {".xlsx", ".xlsm", ".xls"}:
data = pd.read_excel(
path,
sheet_name=sheet if sheet else None,
header=None,
dtype=object,
keep_default_na=False,
)
if isinstance(data, pd.DataFrame):
return {sheet or "sheet1": data}
return {str(k): v for k, v in data.items()}
if ext in {".csv", ".tsv", ".txt"}:
sep = "\t" if ext == ".tsv" else None
try:
df = pd.read_csv(
path,
header=None,
dtype=object,
keep_default_na=False,
sep=sep,
engine="python",
encoding="utf-8-sig",
)
except pd.errors.ParserError:
df = pd.read_csv(
path,
header=None,
dtype=object,
keep_default_na=False,
sep=sep,
engine="python",
encoding="utf-8-sig",
on_bad_lines="warn",
)
return {"csv": df}
raise ValueError(f"unsupported input type: {ext}")
def parse_number(value: Any) -> float | None:
s = norm_cell(value)
if not s or s.lower() in BLANK_TOKENS:
return None
s = CURRENCY_RE.sub("", s)
s = s.replace(",", "").replace("%", "").strip()
neg = s.startswith("(") and s.endswith(")")
s = s.strip("() ")
try:
number = float(s)
return -number if neg else number
except ValueError:
return None
def parse_percent(value: Any) -> float | None:
s = norm_cell(value).lower()
if not s or s in BLANK_TOKENS:
return None
if "bps" in s or "basis" in s:
n = parse_number(s.replace("basis points", "").replace("bps", ""))
return None if n is None else n / 10000.0
n = parse_number(s)
if n is None:
return None
if "%" in s:
return n / 100.0
# Leave ambiguous bare numbers as-is only if they already look like decimal percentages.
return n if abs(n) <= 1 else None
def parse_bool(value: Any) -> bool | None:
s = norm_cell(value).lower()
if s in {"true", "yes", "y", "1"}:
return True
if s in {"false", "no", "n", "0"}:
return False
return None
def parse_dates(series: pd.Series) -> pd.Series:
return pd.to_datetime(series, errors="coerce")
def infer_column_type(header: str, series: pd.Series) -> dict[str, Any]:
values = [norm_cell(v) for v in series.tolist()]
nonblank = [v for v in values if v.lower() not in BLANK_TOKENS]
sample_n = max(min(len(nonblank), 1000), 1)
if not nonblank:
return {"type": "empty", "confidence": 1.0, "format": "general"}
header_id_like = bool(ID_HINT_RE.search(header))
leading_zero = any(re.match(r"^0\d+", v) for v in nonblank[:1000])
bool_ratio = sum(parse_bool(v) is not None for v in nonblank[:1000]) / sample_n
pct_ratio = sum(parse_percent(v) is not None for v in nonblank[:1000]) / sample_n
num_ratio = sum(parse_number(v) is not None for v in nonblank[:1000]) / sample_n
currency_ratio = sum(bool(CURRENCY_RE.search(v)) for v in nonblank[:1000]) / sample_n
date_candidates = [v for v in nonblank[:1000] if not looks_numeric(v)]
if date_candidates:
date_ratio = parse_dates(pd.Series(date_candidates)).notna().sum() / len(date_candidates)
else:
date_ratio = 0.0
if header_id_like or leading_zero:
return {"type": "text_identifier", "confidence": 0.90, "format": "@"}
if bool_ratio >= 0.90:
return {"type": "boolean", "confidence": bool_ratio, "format": "general"}
if pct_ratio >= 0.80:
return {"type": "percent", "confidence": pct_ratio, "format": "0.0%"}
if currency_ratio >= 0.40 and num_ratio >= 0.80:
return {
"type": "currency_amount",
"confidence": min(0.95, (currency_ratio + num_ratio) / 2),
"format": "#,##0.00",
}
if num_ratio >= 0.88:
decimals = any(
(parse_number(v) is not None and abs(parse_number(v) - round(parse_number(v))) > 1e-9)
for v in nonblank[:1000]
)
return {
"type": "number",
"confidence": num_ratio,
"format": "#,##0.00" if decimals else "#,##0",
}
if date_ratio >= 0.85:
return {"type": "date_or_datetime", "confidence": date_ratio, "format": "yyyy-mm-dd"}
if 0.30 <= num_ratio < 0.88 or 0.30 <= date_ratio < 0.85:
return {"type": "mixed", "confidence": max(num_ratio, date_ratio), "format": "general"}
return {"type": "text", "confidence": 0.70, "format": "general"}
def convert_column(series: pd.Series, col_type: str) -> pd.Series:
cleaned = series.map(clean_text_value)
if col_type in {"text", "text_identifier", "mixed", "empty"}:
return cleaned
if col_type == "boolean":
return cleaned.map(lambda v: parse_bool(v) if v is not None else None)
if col_type == "percent":
return cleaned.map(lambda v: parse_percent(v) if v is not None else None)
if col_type in {"number", "currency_amount"}:
return cleaned.map(lambda v: parse_number(v) if v is not None else None)
if col_type == "date_or_datetime":
return parse_dates(cleaned)
return cleaned
def likely_subtotal_mask(df: pd.DataFrame) -> pd.Series:
if df.empty:
return pd.Series([], dtype=bool)
def row_has_total(row: pd.Series) -> bool:
vals = [norm_cell(v) for v in row.tolist()[:5]]
return any(TOTAL_RE.search(v) for v in vals if v)
return df.apply(row_has_total, axis=1)
def infer_domain(sheet_name: str, headers: list[str], override: str) -> str:
if override and override != "auto":
return override
text = " ".join([sheet_name] + headers).lower()
scores: dict[str, int] = {}
for domain, hints in DOMAIN_REQUIRED_HINTS.items():
scores[domain] = sum(1 for h in hints if re.search(r"\b" + re.escape(h) + r"\b", text))
best, score = max(scores.items(), key=lambda x: x[1])
return best if score else "general"
def required_field_checks(
domain: str, clean_df: pd.DataFrame, sheet_name: str
) -> list[dict[str, Any]]:
checks: list[dict[str, Any]] = []
hints = DOMAIN_REQUIRED_HINTS.get(domain, [])
lower_cols = {c.lower(): c for c in clean_df.columns}
for hint in hints:
matches = [actual for lower, actual in lower_cols.items() if hint in lower]
for col in matches[:2]:
missing = int(clean_df[col].isna().sum())
if missing:
checks.append(
{
"severity": "warning",
"issue_type": "missing_domain_field_values",
"sheet": sheet_name,
"field": col,
"affected_count": missing,
"description": f"{missing} rows are missing values in a domain-relevant field.",
"recommended_action": "review whether these rows are incomplete or need enrichment from the source system.",
}
)
return checks
def clean_sheet(
sheet_name: str, raw_df: pd.DataFrame, args: argparse.Namespace
) -> tuple[pd.DataFrame, list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], str]:
audit: list[dict[str, Any]] = []
checks: list[dict[str, Any]] = []
dictionary: list[dict[str, Any]] = []
header_idx = args.header_row - 1 if args.header_row else detect_header_row(raw_df)
raw_headers = raw_df.iloc[header_idx].tolist() if len(raw_df) else []
headers, header_notes = unique_headers(raw_headers, args.header_style)
data = raw_df.iloc[header_idx + 1 :].copy() if len(raw_df) else pd.DataFrame()
if len(headers) == data.shape[1]:
data.columns = headers
audit.append(
{
"step": "header_detection",
"action": f"used row {header_idx + 1} as header",
"basis": "user-specified header row"
if args.header_row
else "automatic header inference",
"affected_sheet": sheet_name,
"affected_field": "all",
"affected_rows_or_count": 1,
"risk_level": "low" if args.header_row else "medium",
"notes": "verify if the source has multi-row headers or report titles.",
}
)
for note in header_notes:
checks.append(
{
"severity": "warning",
"issue_type": note["issue"],
"sheet": sheet_name,
"field": note.get("cleaned"),
"affected_count": 1,
"description": str(note),
"recommended_action": "review header mapping in data_dictionary.",
}
)
# Remove fully empty rows and columns from clean data.
before_rows, before_cols = data.shape
data = data.dropna(how="all")
data = data.loc[:, [not all(is_blank(v) for v in data[col].tolist()) for col in data.columns]]
# Convert blank tokens before another empty-row check.
data = map_dataframe(data, clean_text_value)
data = data.dropna(how="all")
after_rows, after_cols = data.shape
if before_rows != after_rows or before_cols != after_cols:
audit.append(
{
"step": "structural_cleanup",
"action": "removed fully empty rows/columns from clean output",
"basis": "safe structural cleanup; raw_source remains preserved",
"affected_sheet": sheet_name,
"affected_field": "all",
"affected_rows_or_count": f"rows {before_rows}->{after_rows}, columns {before_cols}->{after_cols}",
"risk_level": "low",
"notes": "only fully empty rows/columns were removed.",
}
)
domain = infer_domain(sheet_name, list(data.columns), args.domain)
subtotal_mask = likely_subtotal_mask(data)
subtotal_count = int(subtotal_mask.sum())
if subtotal_count:
checks.append(
{
"severity": "warning",
"issue_type": "possible_total_or_subtotal_rows",
"sheet": sheet_name,
"field": "row",
"affected_count": subtotal_count,
"description": "rows contain labels such as total or subtotal.",
"recommended_action": "remove from detail data only if they are report subtotal rows, not real records.",
}
)
if args.remove_subtotals:
data = data.loc[~subtotal_mask].copy()
audit.append(
{
"step": "subtotal_handling",
"action": "removed possible total/subtotal rows",
"basis": "--remove-subtotals option",
"affected_sheet": sheet_name,
"affected_field": "row",
"affected_rows_or_count": subtotal_count,
"risk_level": "medium",
"notes": "verify these were not legitimate records.",
}
)
# Infer and convert columns.
for original_col in list(data.columns):
info = infer_column_type(original_col, data[original_col])
converted = convert_column(data[original_col], info["type"])
data[original_col] = converted
non_null = data[original_col].dropna()
examples = [str(v) for v in non_null.head(5).tolist()]
dictionary.append(
{
"source_sheet": sheet_name,
"original_field": original_col,
"clean_field": original_col,
"inferred_type": info["type"],
"excel_format": info["format"],
"null_count": int(data[original_col].isna().sum()),
"unique_count": int(non_null.nunique(dropna=True)) if len(non_null) else 0,
"example_values": ", ".join(examples),
"cleaning_notes": f"confidence={round(float(info['confidence']), 3)}",
"business_notes": "",
}
)
if info["type"] == "mixed":
checks.append(
{
"severity": "warning",
"issue_type": "mixed_type_column",
"sheet": sheet_name,
"field": original_col,
"affected_count": int(len(non_null)),
"description": "column contains mixed values that were left mostly as text.",
"recommended_action": "review source values and provide a conversion rule if this field should be numeric/date.",
}
)
if args.dedupe == "exact" and not data.empty:
before = len(data)
data = data.drop_duplicates(keep="first")
removed = before - len(data)
if removed:
audit.append(
{
"step": "duplicate_handling",
"action": "removed exact duplicate rows",
"basis": "--dedupe exact; all cleaned values matched",
"affected_sheet": sheet_name,
"affected_field": "all",
"affected_rows_or_count": removed,
"risk_level": "low",
"notes": "potential non-exact duplicates are not merged by this script.",
}
)
checks.append(
{
"severity": "info",
"issue_type": "exact_duplicate_rows_removed",
"sheet": sheet_name,
"field": "all",
"affected_count": removed,
"description": "exact duplicates were removed from clean output.",
"recommended_action": "review if source row identity matters.",
}
)
checks.extend(required_field_checks(domain, data, sheet_name))
return data.reset_index(drop=True), dictionary, checks, audit, domain
def safe_excel_sheet_name(base: str, used: set[str]) -> str:
name = re.sub(r"[\\/*?:\[\]]", "_", base).strip() or "sheet"
name = name[:31]
original = name
i = 2
while name in used:
suffix = f"_{i}"
name = (original[: 31 - len(suffix)] + suffix)[:31]
i += 1
used.add(name)
return name
def autosize_and_table(path: Path, sheet_formats: dict[str, dict[str, str]]) -> None:
load_workbook, Table, TableStyleInfo, get_column_letter = load_openpyxl_modules()
wb = load_workbook(path)
for ws in wb.worksheets:
ws.freeze_panes = "A2"
max_row = ws.max_row
max_col = ws.max_column
if max_row >= 1 and max_col >= 1:
for cell in ws[1]:
cell.style = "Headline 4"
# Add a table where there is at least one data row and headers are populated.
if max_row >= 2 and all(
ws.cell(1, c).value not in (None, "") for c in range(1, max_col + 1)
):
ref = f"A1:{get_column_letter(max_col)}{max_row}"
table_name = re.sub(r"[^A-Za-z0-9_]", "_", f"tbl_{ws.title}")[:240]
if not table_name or table_name[0].isdigit():
table_name = f"tbl_{table_name}"
tab = Table(displayName=table_name, ref=ref)
style = TableStyleInfo(
name="TableStyleMedium2",
showFirstColumn=False,
showLastColumn=False,
showRowStripes=True,
showColumnStripes=False,
)
tab.tableStyleInfo = style
try:
ws.add_table(tab)
except ValueError:
pass
formats = sheet_formats.get(ws.title, {})
header_to_col = {str(ws.cell(1, c).value): c for c in range(1, max_col + 1)}
for header, fmt in formats.items():
col_idx = header_to_col.get(header)
if col_idx:
for row in range(2, max_row + 1):
ws.cell(row, col_idx).number_format = fmt
for col_idx in range(1, max_col + 1):
col_letter = get_column_letter(col_idx)
max_len = 10
for row in range(1, min(max_row, 200) + 1):
value = ws.cell(row, col_idx).value
if value is not None:
max_len = max(max_len, min(len(str(value)), 60))
ws.column_dimensions[col_letter].width = min(max(max_len + 2, 10), 64)
wb.save(path)
def main() -> None:
parser = argparse.ArgumentParser(
description="Conservatively clean CSV/XLSX data into an audit-ready workbook."
)
parser.add_argument("input", help="input .xlsx, .xls, .csv, .tsv, or .txt file")
parser.add_argument("--output", "-o", default="cleaned.xlsx", help="output .xlsx path")
parser.add_argument("--sheet", help="optional Excel sheet name to clean")
parser.add_argument(
"--domain",
default="auto",
help="auto, finance, capiq_factset_export, cim_table, debt_schedule, process_tracker, qoe_schedule, investing, operations, sales, product, hr, procurement, or general",
)
parser.add_argument(
"--dedupe",
choices=["none", "exact"],
default="exact",
help="duplicate policy for clean output",
)
parser.add_argument("--header-row", type=int, help="1-based header row override")
parser.add_argument(
"--header-style",
choices=["display", "snake"],
default="display",
help="cleaned header style",
)
parser.add_argument(
"--remove-subtotals",
action="store_true",
help="remove rows that appear to be totals/subtotals",
)
args = parser.parse_args()
input_path = Path(args.input)
output_path = Path(args.output)
try:
load_openpyxl_modules()
except DependencyPreflightError as exc:
parser.exit(2, f"{parser.prog}: error: {exc}\n")
tables = read_input(input_path, args.sheet)
all_dictionary: list[dict[str, Any]] = []
all_checks: list[dict[str, Any]] = []
all_audit: list[dict[str, Any]] = []
summary_rows: list[dict[str, Any]] = []
sheet_formats: dict[str, dict[str, str]] = {}
used_sheet_names: set[str] = set()
with pd.ExcelWriter(output_path, engine="openpyxl") as writer:
pd.DataFrame(
[
{
"item": "First read",
"detail": "Use this Cover tab first; cleaned data, raw source, QA, and audit tabs follow.",
},
{"item": "Input", "detail": str(input_path)},
{"item": "Source tables", "detail": len(tables)},
{
"item": "Cleaner posture",
"detail": "conservative first-pass cleaning; ambiguous business rules remain in quality_checks and assumptions_audit",
},
{
"item": "Support artifacts",
"detail": "No raw CSV/JSON support files are user-facing by default; manifest.json is an agent audit file.",
},
]
).to_excel(writer, sheet_name="Cover", index=False)
for source_name, raw_df in tables.items():
clean_df, dictionary, checks, audit, domain = clean_sheet(source_name, raw_df, args)
clean_sheet_name = safe_excel_sheet_name(
"clean_data" if len(tables) == 1 else f"clean_{source_name}", used_sheet_names
)
raw_sheet_name = safe_excel_sheet_name(
"raw_source" if len(tables) == 1 else f"raw_{source_name}", used_sheet_names
)
clean_df.to_excel(writer, sheet_name=clean_sheet_name, index=False)
raw_df.to_excel(writer, sheet_name=raw_sheet_name, index=False, header=False)
fmt_map = {
row["clean_field"]: row["excel_format"]
for row in dictionary
if row.get("excel_format") not in {None, "general"}
}
sheet_formats[clean_sheet_name] = fmt_map
all_dictionary.extend(dictionary)
all_checks.extend(checks)
all_audit.extend(audit)
summary_rows.append(
{
"source_sheet": source_name,
"clean_sheet": clean_sheet_name,
"raw_sheet": raw_sheet_name,
"inferred_domain": domain,
"clean_rows": len(clean_df),
"clean_columns": len(clean_df.columns),
"quality_issue_count": len(checks),
}
)
pd.DataFrame(summary_rows).to_excel(writer, sheet_name="summary", index=False)
pd.DataFrame(all_dictionary or [{"note": "no columns profiled"}]).to_excel(
writer, sheet_name="data_dictionary", index=False
)
pd.DataFrame(
all_checks
or [
{
"severity": "info",
"issue_type": "no_issues_logged",
"description": "no quality issues were logged by the deterministic cleaner",
}
]
).to_excel(writer, sheet_name="quality_checks", index=False)
all_audit.append(
{
"step": "workbook_creation",
"action": "created cleaned workbook",
"basis": "deterministic first-pass cleaning script",
"affected_sheet": "all",
"affected_field": "all",
"affected_rows_or_count": len(tables),
"risk_level": "low",
"notes": f"generated_at={datetime.now(timezone.utc).isoformat()}; input={input_path}",
}
)
pd.DataFrame(all_audit).to_excel(writer, sheet_name="assumptions_audit", index=False)
autosize_and_table(output_path, sheet_formats)
write_artifact_manifest(
output_path.parent,
"excel-data-cleaner",
"workbook",
output_path,
extra={
"inputs": {
"input": str(input_path),
"sheet": args.sheet or "",
"domain": args.domain,
"dedupe": args.dedupe,
}
},
)
print(f"wrote cleaned workbook to {output_path}")
if __name__ == "__main__":
main()
SHA-256: 8daf4c3df1f1c69a1a4a88e3c1ef62e45b9c0718710ed65df227ad70f0d58437