From 94eacc2e4684830908a78228edaa648ccc349e62 Mon Sep 17 00:00:00 2001 From: Tuff Tech Date: Sat, 26 Sep 2026 15:08:17 -0700 Subject: [PATCH] Add files via upload --- Excel-CSV-File-Merger-main/LICENSE | 21 + Excel-CSV-File-Merger-main/README.md | 157 +++++++ .../create_samples.cpython-312.pyc | Bin 0 -> 4269 bytes .../__pycache__/excel_merger.cpython-312.pyc | Bin 0 -> 17625 bytes Excel-CSV-File-Merger-main/create_samples.py | 133 ++++++ Excel-CSV-File-Merger-main/excel_merger.py | 418 ++++++++++++++++++ Excel-CSV-File-Merger-main/merger.log | 8 + Excel-CSV-File-Merger-main/requirements.txt | 3 + Excel-CSV-File-Merger-main/ruff.toml | 9 + .../sample_input/hr_employees.csv | 8 + .../sample_input/marketing_leads.csv | 7 + .../sample_input/ops_data.xlsx | Bin 0 -> 5157 bytes .../sample_input/sales_q1.xlsx | Bin 0 -> 5141 bytes .../sample_input/sales_q2.xlsx | Bin 0 -> 5259 bytes ..._excel_merger.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 37597 bytes .../tests/test_excel_merger.py | 225 ++++++++++ 16 files changed, 989 insertions(+) create mode 100644 Excel-CSV-File-Merger-main/LICENSE create mode 100644 Excel-CSV-File-Merger-main/README.md create mode 100644 Excel-CSV-File-Merger-main/__pycache__/create_samples.cpython-312.pyc create mode 100644 Excel-CSV-File-Merger-main/__pycache__/excel_merger.cpython-312.pyc create mode 100644 Excel-CSV-File-Merger-main/create_samples.py create mode 100644 Excel-CSV-File-Merger-main/excel_merger.py create mode 100644 Excel-CSV-File-Merger-main/merger.log create mode 100644 Excel-CSV-File-Merger-main/requirements.txt create mode 100644 Excel-CSV-File-Merger-main/ruff.toml create mode 100644 Excel-CSV-File-Merger-main/sample_input/hr_employees.csv create mode 100644 Excel-CSV-File-Merger-main/sample_input/marketing_leads.csv create mode 100644 Excel-CSV-File-Merger-main/sample_input/ops_data.xlsx create mode 100644 Excel-CSV-File-Merger-main/sample_input/sales_q1.xlsx create mode 100644 Excel-CSV-File-Merger-main/sample_input/sales_q2.xlsx create mode 100644 Excel-CSV-File-Merger-main/tests/__pycache__/test_excel_merger.cpython-312-pytest-9.1.1.pyc create mode 100644 Excel-CSV-File-Merger-main/tests/test_excel_merger.py diff --git a/Excel-CSV-File-Merger-main/LICENSE b/Excel-CSV-File-Merger-main/LICENSE new file mode 100644 index 0000000..b04d288 --- /dev/null +++ b/Excel-CSV-File-Merger-main/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tuff-Tech-coder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Excel-CSV-File-Merger-main/README.md b/Excel-CSV-File-Merger-main/README.md new file mode 100644 index 0000000..c00b8fa --- /dev/null +++ b/Excel-CSV-File-Merger-main/README.md @@ -0,0 +1,157 @@ +# Excel & CSV File Merger + +![Python](https://img.shields.io/badge/Python-3.11+-3776AB?logo=python&logoColor=white) +![pandas](https://img.shields.io/badge/pandas-3.x-150458?logo=pandas&logoColor=white) +![openpyxl](https://img.shields.io/badge/openpyxl-3.x-1D6F42) +![Tests](https://img.shields.io/badge/tests-34%20passing-brightgreen) +![License](https://img.shields.io/badge/License-MIT-green) + +A data-normalization pipeline that scans a folder for Excel and CSV files and intelligently merges them into a single, clean master workbook. Built specifically for the **messy reality** of business data: mismatched column orders, inconsistent naming, currency stored as text, stray whitespace, blank rows, and duplicates. + +--- + +## Why it's useful + +Combining spreadsheets from different teams by hand is slow and error-prone — column names never match, totals are formatted as `"$5,400.00"`, and duplicates creep in. This tool encodes those fixes once and applies them consistently, turning a pile of inconsistent files into one analysis-ready dataset with an audit trail. + +--- + +## Features + +- **Auto-discovery** of every `.xlsx`, `.xls`, and `.csv` file in a folder. +- **Column-alias mapping** — a configurable dictionary unifies variants (`"Sales Amount"`, `"Amount"`, `"Salary"` → `revenue`; `"Territory"` → `region`) so files merge on meaning, not exact spelling. +- **Currency normalization** — `"$5,400.00"` becomes `5400.0` for real math. +- **Cleanup pass** — strips cell whitespace and drops entirely empty rows. +- **Graceful column alignment** — files missing columns still merge cleanly, with gaps filled rather than erroring. +- **Configurable deduplication** of identical rows (`--no-dedup` to disable). +- **Source tracking** — a `source_file` column records each row's origin. +- **Polished two-sheet output** — a formatted *Merged Data* sheet (styled header, alternating row shading, auto-fit columns) plus a *Merge Summary* sheet with per-file counts, duplicates removed, and final totals. + +--- + +## Tech stack + +`Python` · `pandas` · `openpyxl` · `xlrd` · `argparse` · `logging` · `regex` + +--- + +## Project structure + +``` +├── excel_merger.py # Main script +├── create_samples.py # Generates the demo input files +├── requirements.txt # Python dependencies +├── tests/ # 26 unit tests +├── merged_master.xlsx # Generated: merged output (gitignored) +├── merger.log # Generated: application log +└── sample_input/ + ├── sales_q1.xlsx # Standard sales data + ├── sales_q2.xlsx # Different column order + extra column + a duplicate + ├── hr_employees.csv # Missing columns, different naming + ├── marketing_leads.csv # Currency-as-text, whitespace, varied date formats + └── ops_data.xlsx # Renamed columns + blank rows +``` + +--- + +## Setup + +```bash +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt +``` + +--- + +## Usage + +```bash +# Merge the included sample files +python excel_merger.py --input ./sample_input --output merged_master.xlsx + +# Merge your own folder, keep duplicates +python excel_merger.py --input /path/to/folder --no-dedup +``` + +**Options:** `--input` · `--output` · `--no-dedup` + +--- + +## How it works + +1. Discovers all supported files in the input folder. +2. Loads each, normalizes column names through the alias map, cleans cells, normalizes currency, drops empty rows, and tags rows with their source file. +3. Concatenates everything (pandas aligns on column name, filling gaps). +4. Optionally deduplicates on all columns except the source tag. +5. Writes a formatted workbook with both the merged data and a summary sheet. + +--- + +## Customizing: the `COLUMN_ALIASES` extension point + +This is the most reusable idea in the repo. `COLUMN_ALIASES` is a single +dictionary mapping every known source header onto a canonical name: + +```python +COLUMN_ALIASES: dict[str, str] = { + "full name": "name", + "employee name": "name", + "sales amount": "revenue", + "amount": "revenue", + "salary": "revenue", + "territory": "region", + ... +} +``` + +Teaching the merger a new file format is **one line in this dict** — no other +code changes. Everything downstream (currency normalization, deduplication, +column alignment, the summary sheet) keys off canonical names, so the entire +pipeline picks up the new variant automatically. Schema drift becomes +configuration rather than a code change. + +### Two caveats worth knowing + +**Aliasing can collide.** Several headers intentionally map to the same +canonical name — `Amount`, `Total` and `Salary` all become `revenue`. If a +*single file* contains two of them, they cannot both be `revenue`. `dedupe_columns()` +renames the second to `revenue_2` and logs a warning naming the file: + +``` +marketing_leads.csv: duplicate canonical column 'revenue' renamed to 'revenue_2'. +Review COLUMN_ALIASES if these should be merged. +``` + +Nothing is silently dropped, and nothing crashes — you get both columns plus a +prompt to decide whether that mapping was right for your data. Note that only +the canonical `revenue` column gets currency normalization; `revenue_2` is left +as-is precisely because the tool should not guess which one you meant. + +**`"first name" → "name"` is lossy.** If a file has separate `First Name` and +`Last Name` columns, only the first is mapped to `name` and the surname stays +under its own column. That is a deliberate simplification, not a bug — but if +your data splits names that way, either remove that alias or pre-join the two +columns before merging. + +--- + +## Development + +```bash +pip install -r requirements.txt +pip install pytest ruff + +pytest -q # 34 tests +ruff check . +``` + +The suite covers alias normalization, currency parsing, whitespace cleaning, +deduplication and source-file tagging, including a regression test for the +alias-collision crash described above. + +--- + +## Possible extensions + +Add per-column type coercion, configurable merge keys for true joins (not just concatenation), a dry-run preview, or output to Parquet/SQL for larger datasets. diff --git a/Excel-CSV-File-Merger-main/__pycache__/create_samples.cpython-312.pyc b/Excel-CSV-File-Merger-main/__pycache__/create_samples.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d3ebf6839caed851e4179506e5974b8db780aade GIT binary patch literal 4269 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 Y1Shzrlkb}A-xtJh_Ck3T8ra7DfB!u%2mk;8 literal 0 HcmV?d00001 diff --git a/Excel-CSV-File-Merger-main/create_samples.py b/Excel-CSV-File-Merger-main/create_samples.py new file mode 100644 index 0000000..dd60762 --- /dev/null +++ b/Excel-CSV-File-Merger-main/create_samples.py @@ -0,0 +1,133 @@ +"""Create five deliberately messy sample input files for the Excel Merger demo.""" + +import csv +from pathlib import Path + +import pandas as pd + +OUT = Path(__file__).parent / "sample_input" + + +def create_samples(output_folder: Path = OUT) -> None: + """Generate the demo workbooks and CSV files in ``output_folder``.""" + output_folder.mkdir(parents=True, exist_ok=True) + + # ── File 1: sales_q1.xlsx ───────────────────────────────────────────── + # Standard sales data, some mixed-case headers + df1 = pd.DataFrame({ + "Name": ["Alice Johnson", "Bob Smith", "Carol White", "Dave Brown", "Eve Davis"], + "Email": [ + "alice@corp.com", + "bob@corp.com", + "carol@corp.com", + "dave@corp.com", + "eve@corp.com", + ], + "Revenue": [12500.00, 8750.50, 23400.00, 5600.75, 19800.00], + "Region": ["North", "South", "North", "West", "East"], + "Date": ["2024-01-15", "2024-01-20", "2024-02-03", "2024-02-14", "2024-03-01"], + "Department": ["Sales", "Sales", "Sales", "Sales", "Sales"], + }) + df1.to_excel(output_folder / "sales_q1.xlsx", index=False) + + # ── File 2: sales_q2.xlsx ───────────────────────────────────────────── + # Same data type but different column order and some extra columns + df2 = pd.DataFrame({ + "email": [ + "frank@corp.com", + "grace@corp.com", + "henry@corp.com", + "irene@corp.com", + "jake@corp.com", + "alice@corp.com", + ], + "REVENUE": [7200.00, 15300.00, 9800.00, 22100.00, 6400.00, 12500.00], + "Region": ["South", "East", "North", "West", "South", "North"], + "name": ["Frank Lee", "Grace Kim", "Henry Chen", "Irene Park", "Jake Wu", "Alice Johnson"], + "Date": [ + "2024-04-10", + "2024-04-22", + "2024-05-08", + "2024-05-19", + "2024-06-01", + "2024-01-15", + ], + "Notes": ["Top performer", "", "Needs review", "Excellent", "", "Duplicate entry"], + "Department": ["Sales", "Sales", "Sales", "Sales", "Sales", "Sales"], + }) + df2.to_excel(output_folder / "sales_q2.xlsx", index=False) + + # ── File 3: hr_employees.csv ───────────────────────────────────────── + # CSV format, missing Revenue and Date columns, extra HR-specific columns + df3 = pd.DataFrame({ + "Name": [ + "Laura Moss", + "Mike Stone", + "Nancy Hill", + "Oscar Reed", + "Paula Marsh", + "Quinn Ford", + "Rachel Moore", + ], + "Email": [ + "laura@corp.com", + "mike@corp.com", + "nancy@corp.com", + "oscar@corp.com", + "paula@corp.com", + "quinn@corp.com", + "rachel@corp.com", + ], + "Department": ["HR", "HR", "Engineering", "Engineering", "Marketing", "Marketing", "HR"], + "Region": ["East", "West", "North", "South", "East", "West", "North"], + "Hire Date": [ + "2021-03-15", + "2019-07-01", + "2022-11-20", + "2020-04-05", + "2023-01-10", + "2018-09-30", + "2024-02-15", + ], + "Salary": [55000, 72000, 98000, 115000, 61000, 68000, 52000], + }) + df3.to_csv(output_folder / "hr_employees.csv", index=False) + + # ── File 4: marketing_leads.csv ────────────────────────────────────── + # CSV with inconsistent formatting: extra whitespace, mixed number formats + rows = [ + [" Name ", "Email", "Revenue", " Region ", "Date", "Department"], + ["Sam Turner ", "sam@leads.com", "$5,400.00", " North ", "2024/01/08", "Marketing"], + [" Tina Brooks", "tina@leads.com", "8200", "East", "2024-02-14", "Marketing"], + ["Uma Patel ", "uma@leads.com", "$11,750.50", "West ", "March 5, 2024", "Marketing"], + ["Vince Hall", "vince@leads.com", "3100.0", "South", "2024-04-22", "Marketing"], + [" Wendy Fox", "wendy@leads.com", "$19,900", "North", "2024-05-30", "Marketing"], + ["Xavier Long", "xavier@leads.com", "7650.25", " East", "2024-06-15", "Marketing"], + ] + with (output_folder / "marketing_leads.csv").open("w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + writer.writerows(rows) + + # ── File 5: ops_data.xlsx ──────────────────────────────────────────── + # Different column names, some blank rows, purely numeric revenue + df5 = pd.DataFrame({ + "Full Name": ["Yara Singh", "Zach Adams", None, "Amy Clarke", "Brian Duke"], + "Contact Email": [ + "yara@ops.com", + "zach@ops.com", + None, + "amy@ops.com", + "brian@ops.com", + ], + "Sales Amount": [14200.00, 9900.50, None, 31000.00, 7800.25], + "Territory": ["West", "North", None, "South", "East"], + "Transaction Date": ["2024-03-18", "2024-04-02", None, "2024-05-11", "2024-06-29"], + "Team": ["Operations", "Operations", None, "Operations", "Operations"], + }) + df5.to_excel(output_folder / "ops_data.xlsx", index=False) + + print(f"[OK] Created 5 sample input files in {output_folder}") + + +if __name__ == "__main__": + create_samples() diff --git a/Excel-CSV-File-Merger-main/excel_merger.py b/Excel-CSV-File-Merger-main/excel_merger.py new file mode 100644 index 0000000..88fe8ae --- /dev/null +++ b/Excel-CSV-File-Merger-main/excel_merger.py @@ -0,0 +1,418 @@ +""" +Excel & CSV File Merger +======================= +Scans a folder for all Excel (.xlsx, .xls) and CSV files, intelligently +merges them into a single normalized master file, and outputs a clean Excel +file. Handles messy real-world data: different column orders, missing columns, +duplicate rows, inconsistent formatting, and varied column naming conventions. + +Usage: + python excel_merger.py --input ./sample_input --output merged_master.xlsx + python excel_merger.py --input /path/to/folder --no-dedup + +Features: + - Discovers all .xlsx, .xls, and .csv files recursively + - Normalizes column names (strips whitespace, lowercases for matching) + - Maps common column aliases (e.g. "Sales Amount" → "revenue") + - Fills missing columns with empty values + - Deduplicates identical rows (configurable) + - Strips leading/trailing whitespace from all text cells + - Normalizes currency strings ("$5,400.00" → 5400.00) + - Adds a "source_file" column tracking which file each row came from + - Outputs a formatted Excel file with statistics summary sheet +""" + +import argparse +import logging +import re +from pathlib import Path + +import pandas as pd +from openpyxl.styles import Alignment, Font, PatternFill + +logger = logging.getLogger(__name__) + +SUPPORTED_SUFFIXES = {".xlsx", ".xls", ".csv"} +SOURCE_COLUMN = "source_file" + + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +def configure_logging(log_path: Path = Path("merger.log")) -> None: + """Configure console and file logging for the command-line entry point. + + Logging is deliberately configured at runtime rather than import time so + importing this module as a library neither creates a file nor fails merely + because the current directory is read-only. + """ + handlers: list[logging.Handler] = [logging.StreamHandler()] + file_error: OSError | None = None + try: + # Input files are arbitrary user data, so the log is explicitly UTF-8 + # rather than the platform default (cp1252 on Windows). + handlers.append(logging.FileHandler(log_path, encoding="utf-8")) + except OSError as exc: + file_error = exc + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=handlers, + force=True, + ) + if file_error is not None: + logger.warning("Could not write log file %s: %s", log_path, file_error) + +# --------------------------------------------------------------------------- +# Column alias map — maps known variants to a canonical name +# Add more entries here as you encounter new file formats. +# --------------------------------------------------------------------------- +COLUMN_ALIASES: dict[str, str] = { + # Name variants + "full name": "name", + "full_name": "name", + "first name": "name", + "employee name": "name", + # Email variants + "contact email": "email", + "email address": "email", + "e-mail": "email", + # Revenue / money variants + "revenue": "revenue", + "sales amount": "revenue", + "sales_amount": "revenue", + "amount": "revenue", + "salary": "revenue", + "total": "revenue", + # Region / territory variants + "region": "region", + "territory": "region", + "area": "region", + "zone": "region", + # Date variants + "date": "date", + "transaction date": "date", + "transaction_date": "date", + "hire date": "date", + "hire_date": "date", + "sale date": "date", + # Department variants + "department": "department", + "dept": "department", + "team": "department", + "division": "department", +} + + +def normalize_column_name(col: str) -> str: + """ + Lowercase, strip whitespace, and apply alias mapping. + Returns the canonical column name. + """ + cleaned = str(col).strip().lower() + return COLUMN_ALIASES.get(cleaned, cleaned) + + +def dedupe_columns(cols: list[str], source_filename: str) -> list[str]: + """ + Ensure canonical column names are unique within a single file. + + Multiple source headers can alias to the same canonical name (e.g. both + "Amount" and "Total" map to "revenue"). Left unhandled, pandas produces two + identically-named columns, `df["revenue"]` returns a DataFrame instead of a + Series, and every downstream scalar operation raises + "The truth value of a Series is ambiguous". + """ + used: set[str] = set() + next_suffix: dict[str, int] = {} + out: list[str] = [] + for c in cols: + renamed = c + if renamed in used: + suffix = next_suffix.get(c, 2) + renamed = f"{c}_{suffix}" + while renamed in used: + suffix += 1 + renamed = f"{c}_{suffix}" + next_suffix[c] = suffix + 1 + logger.warning( + f"{source_filename}: duplicate canonical column '{c}' renamed to " + f"'{renamed}'. Review COLUMN_ALIASES if these should be merged." + ) + used.add(renamed) + next_suffix.setdefault(c, 2) + out.append(renamed) + return out + + +def normalize_currency(value) -> float | None: + """ + Convert currency strings like '$5,400.00' or '5400' to float. + Returns None if conversion is not possible. + """ + if isinstance(value, (pd.Series, pd.DataFrame)): + raise TypeError( + "normalize_currency expects a scalar. Receiving a Series means the " + "DataFrame has duplicate column names -- run dedupe_columns() first." + ) + if pd.isna(value): + return None + raw = str(value).strip() + # Remove currency symbols and thousands separators + cleaned = re.sub(r"[^\d.\-]", "", raw) + try: + return float(cleaned) if cleaned else None + except ValueError: + return None + + +def clean_string(value) -> str: + """Strip whitespace from string values; leave non-strings unchanged.""" + if isinstance(value, str): + return value.strip() + return value + + +# --------------------------------------------------------------------------- +# File loading +# --------------------------------------------------------------------------- +def load_file(path: Path) -> pd.DataFrame | None: + """ + Load an Excel or CSV file into a DataFrame. + Returns None if the file cannot be read. + """ + try: + suffix = path.suffix.lower() + if suffix in (".xlsx", ".xls"): + df = pd.read_excel(path, engine="openpyxl" if suffix == ".xlsx" else "xlrd") + elif suffix == ".csv": + df = pd.read_csv(path) + else: + logger.warning(f"Unsupported file type: {path.name}") + return None + + logger.info(f"Loaded {path.name}: {len(df)} rows, {len(df.columns)} columns") + return df + + except Exception as e: + logger.error(f"Failed to load {path.name}: {e}") + return None + + +# --------------------------------------------------------------------------- +# Per-file normalization +# --------------------------------------------------------------------------- +def normalize_dataframe(df: pd.DataFrame, source_filename: str) -> pd.DataFrame: + """ + Apply all normalization steps to a single DataFrame: + 1. Normalize column names (strip + alias mapping) + 2. Strip whitespace from string cells + 3. Drop entirely empty rows + 4. Normalize currency values in 'revenue' column + 5. Add source_file column + """ + # Work on a copy so callers do not get partially mutated input if a later + # normalization step fails. + df = df.copy() + + # --- 1. Normalize column names --- + # Reserve SOURCE_COLUMN before normalizing user columns. Otherwise an input + # column named "source_file" would be silently overwritten in step 5. + normalized_columns = [normalize_column_name(c) for c in df.columns] + df.columns = dedupe_columns( + [SOURCE_COLUMN, *normalized_columns], source_filename + )[1:] + + # --- 2. Strip whitespace from all string cells --- + df = df.map(clean_string) + + # --- 3. Drop rows where ALL values are missing or empty after trimming --- + # dropna alone does not consider "" empty, so a row made entirely of + # whitespace survived step 2 in earlier versions. + empty_cells = df.isna() | df.eq("") + df = df.loc[~empty_cells.all(axis=1)].copy() + + # --- 4. Normalize revenue column --- + if "revenue" in df.columns: + df["revenue"] = df["revenue"].apply(normalize_currency) + + # --- 5. Tag with source --- + df[SOURCE_COLUMN] = source_filename + + return df + + +# --------------------------------------------------------------------------- +# Merge engine +# --------------------------------------------------------------------------- +def merge_files( + input_folder: Path, + output_path: Path, + deduplicate: bool = True, +) -> None: + """ + Main merge function. Discovers files, normalizes each, aligns columns, + concatenates, deduplicates, and writes to Excel. + """ + # --- Discover all supported files --- + # Inspect suffixes case-insensitively so names such as DATA.CSV work on + # case-sensitive platforms too. The requested output is excluded so a + # rerun cannot ingest its own previous result. + output_resolved = output_path.resolve() + found_files = sorted( + ( + path + for path in input_folder.rglob("*") + if path.is_file() + and path.suffix.lower() in SUPPORTED_SUFFIXES + and not path.name.startswith("~$") + and path.resolve() != output_resolved + ), + key=lambda path: path.relative_to(input_folder).as_posix().casefold(), + ) + + if not found_files: + raise FileNotFoundError(f"No Excel or CSV files found in: {input_folder}") + + discovered_names = [path.relative_to(input_folder).as_posix() for path in found_files] + logger.info(f"Found {len(found_files)} file(s) to merge: {discovered_names}") + + # --- Load and normalize each file --- + frames: list[pd.DataFrame] = [] + load_stats: list[dict] = [] + + for path in found_files: + source_filename = path.relative_to(input_folder).as_posix() + df = load_file(path) + if df is None: + continue + original_rows = len(df) + df = normalize_dataframe(df, source_filename) + frames.append(df) + load_stats.append({ + "file": source_filename, + "rows_loaded": original_rows, + "columns": len(df.columns) - 1, # exclude source_file + }) + + if not frames: + raise ValueError("No files could be successfully loaded") + + # --- Concatenate (pandas aligns on column names, fills missing with NaN) --- + merged = pd.concat(frames, ignore_index=True, sort=False) + rows_before_dedup = len(merged) + logger.info(f"Combined total: {rows_before_dedup} rows") + + # --- Deduplicate (exclude source_file from key) --- + dupes_removed = 0 + if deduplicate: + key_cols = [c for c in merged.columns if c != SOURCE_COLUMN] + merged.drop_duplicates(subset=key_cols, keep="first", inplace=True) + merged.reset_index(drop=True, inplace=True) + dupes_removed = rows_before_dedup - len(merged) + if dupes_removed: + logger.info(f"Removed {dupes_removed} duplicate row(s)") + + # --- Reorder: put source_file last --- + cols = [c for c in merged.columns if c != SOURCE_COLUMN] + [SOURCE_COLUMN] + merged = merged[cols] + + # --- Write output --- + write_excel(merged, output_path, load_stats, dupes_removed) + logger.info(f"Merge complete: {len(merged)} rows written to {output_path}") + print(f"\n[OK] Merged {len(frames)} files -> {len(merged)} rows -> {output_path}") + + +# --------------------------------------------------------------------------- +# Excel writer with formatting +# --------------------------------------------------------------------------- +def write_excel(df: pd.DataFrame, output_path: Path, stats: list[dict], dupes_removed: int) -> None: + """Write the merged DataFrame plus a summary sheet to a formatted Excel file.""" + output_path.parent.mkdir(parents=True, exist_ok=True) + with pd.ExcelWriter(output_path, engine="openpyxl") as writer: + # --- Main data sheet --- + df.to_excel(writer, index=False, sheet_name="Merged Data") + _format_sheet(writer.sheets["Merged Data"], df) + + # --- Summary sheet --- + summary_rows = [] + for s in stats: + summary_rows.append(s) + summary_rows.append({"file": "─" * 20, "rows_loaded": "─" * 10, "columns": "─" * 10}) + summary_rows.append({ + "file": "TOTAL", + "rows_loaded": sum(s["rows_loaded"] for s in stats), + "columns": "N/A", + }) + summary_rows.append({"file": "Duplicates removed", "rows_loaded": dupes_removed, "columns": ""}) + summary_rows.append({"file": "Final row count", "rows_loaded": len(df), "columns": ""}) + + summary_df = pd.DataFrame(summary_rows, columns=["file", "rows_loaded", "columns"]) + summary_df.columns = ["Source File", "Rows Loaded", "Original Columns"] + summary_df.to_excel(writer, index=False, sheet_name="Merge Summary") + _format_sheet(writer.sheets["Merge Summary"], summary_df) + + +def _format_sheet(ws, df: pd.DataFrame) -> None: + """Apply header styling and auto-fit columns to a worksheet.""" + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="1A3A5C", end_color="1A3A5C", fill_type="solid") + + for cell in ws[1]: + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal="center", wrap_text=True) + + # Auto-fit column widths based on content + for col in ws.columns: + max_len = max( + (len(str(cell.value or "")) for cell in col), + default=10, + ) + ws.column_dimensions[col[0].column_letter].width = min(max_len + 2, 50) + + # Alternate row shading for readability + light_fill = PatternFill(start_color="F0F4F8", end_color="F0F4F8", fill_type="solid") + for row_idx, row in enumerate(ws.iter_rows(min_row=2), start=2): + if row_idx % 2 == 0: + for cell in row: + cell.fill = light_fill + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def main(): + configure_logging() + parser = argparse.ArgumentParser(description="Excel & CSV File Merger") + parser.add_argument( + "--input", + default="./sample_input", + help="Folder containing Excel/CSV files to merge (default: ./sample_input)", + ) + parser.add_argument( + "--output", + default="merged_master.xlsx", + help="Output Excel filename (default: merged_master.xlsx)", + ) + parser.add_argument( + "--no-dedup", + action="store_true", + help="Skip duplicate row removal", + ) + args = parser.parse_args() + + input_folder = Path(args.input) + if not input_folder.is_dir(): + raise ValueError(f"Input path is not a directory: {input_folder}") + + merge_files( + input_folder=input_folder, + output_path=Path(args.output), + deduplicate=not args.no_dedup, + ) + + +if __name__ == "__main__": + main() diff --git a/Excel-CSV-File-Merger-main/merger.log b/Excel-CSV-File-Merger-main/merger.log new file mode 100644 index 0000000..34d10c2 --- /dev/null +++ b/Excel-CSV-File-Merger-main/merger.log @@ -0,0 +1,8 @@ +2026-09-26 09:38:40,994 [INFO] Found 5 file(s) to merge: ['hr_employees.csv', 'marketing_leads.csv', 'ops_data.xlsx', 'sales_q1.xlsx', 'sales_q2.xlsx'] +2026-09-26 09:38:40,995 [INFO] Loaded hr_employees.csv: 7 rows, 6 columns +2026-09-26 09:38:40,999 [INFO] Loaded marketing_leads.csv: 6 rows, 6 columns +2026-09-26 09:38:41,004 [INFO] Loaded ops_data.xlsx: 5 rows, 6 columns +2026-09-26 09:38:41,008 [INFO] Loaded sales_q1.xlsx: 5 rows, 6 columns +2026-09-26 09:38:41,013 [INFO] Loaded sales_q2.xlsx: 6 rows, 7 columns +2026-09-26 09:38:41,015 [INFO] Combined total: 28 rows +2026-09-26 09:38:41,039 [INFO] Merge complete: 28 rows written to C:\Users\Dan\Documents\Codex\2026-09-26\cou\work\final-smoke\merged.xlsx diff --git a/Excel-CSV-File-Merger-main/requirements.txt b/Excel-CSV-File-Merger-main/requirements.txt new file mode 100644 index 0000000..2e782a2 --- /dev/null +++ b/Excel-CSV-File-Merger-main/requirements.txt @@ -0,0 +1,3 @@ +pandas==3.0.3 +openpyxl==3.1.5 +xlrd==2.0.2 diff --git a/Excel-CSV-File-Merger-main/ruff.toml b/Excel-CSV-File-Merger-main/ruff.toml new file mode 100644 index 0000000..2095aae --- /dev/null +++ b/Excel-CSV-File-Merger-main/ruff.toml @@ -0,0 +1,9 @@ +line-length = 100 +target-version = "py311" + +[lint] +select = ["E", "F", "I", "UP", "B", "SIM"] +ignore = ["E501"] + +[lint.per-file-ignores] +"tests/*" = ["E402"] diff --git a/Excel-CSV-File-Merger-main/sample_input/hr_employees.csv b/Excel-CSV-File-Merger-main/sample_input/hr_employees.csv new file mode 100644 index 0000000..2ea6620 --- /dev/null +++ b/Excel-CSV-File-Merger-main/sample_input/hr_employees.csv @@ -0,0 +1,8 @@ +Name,Email,Department,Region,Hire Date,Salary +Laura Moss,laura@corp.com,HR,East,2021-03-15,55000 +Mike Stone,mike@corp.com,HR,West,2019-07-01,72000 +Nancy Hill,nancy@corp.com,Engineering,North,2022-11-20,98000 +Oscar Reed,oscar@corp.com,Engineering,South,2020-04-05,115000 +Paula Marsh,paula@corp.com,Marketing,East,2023-01-10,61000 +Quinn Ford,quinn@corp.com,Marketing,West,2018-09-30,68000 +Rachel Moore,rachel@corp.com,HR,North,2024-02-15,52000 diff --git a/Excel-CSV-File-Merger-main/sample_input/marketing_leads.csv b/Excel-CSV-File-Merger-main/sample_input/marketing_leads.csv new file mode 100644 index 0000000..524a299 --- /dev/null +++ b/Excel-CSV-File-Merger-main/sample_input/marketing_leads.csv @@ -0,0 +1,7 @@ + Name ,Email,Revenue, Region ,Date,Department +Sam Turner ,sam@leads.com,"$5,400.00", North ,2024/01/08,Marketing + Tina Brooks,tina@leads.com,8200,East,2024-02-14,Marketing +Uma Patel ,uma@leads.com,"$11,750.50",West ,"March 5, 2024",Marketing +Vince Hall,vince@leads.com,3100.0,South,2024-04-22,Marketing + Wendy Fox,wendy@leads.com,"$19,900",North,2024-05-30,Marketing +Xavier Long,xavier@leads.com,7650.25, East,2024-06-15,Marketing diff --git a/Excel-CSV-File-Merger-main/sample_input/ops_data.xlsx b/Excel-CSV-File-Merger-main/sample_input/ops_data.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..fe79f5517b825485cead0248f770fbdd7c16dc85 GIT binary patch literal 5157 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 literal 0 HcmV?d00001 diff --git a/Excel-CSV-File-Merger-main/sample_input/sales_q2.xlsx b/Excel-CSV-File-Merger-main/sample_input/sales_q2.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..fb74ccdc224b16431e54fece0afa5af7a4f845e7 GIT binary patch literal 5259 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 literal 0 HcmV?d00001 diff --git a/Excel-CSV-File-Merger-main/tests/__pycache__/test_excel_merger.cpython-312-pytest-9.1.1.pyc b/Excel-CSV-File-Merger-main/tests/__pycache__/test_excel_merger.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..11c4b2191e86d7acd989f6eb89ac9ecc6f2b9efe GIT binary patch literal 37597 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 literal 0 HcmV?d00001 diff --git a/Excel-CSV-File-Merger-main/tests/test_excel_merger.py b/Excel-CSV-File-Merger-main/tests/test_excel_merger.py new file mode 100644 index 0000000..a22756e --- /dev/null +++ b/Excel-CSV-File-Merger-main/tests/test_excel_merger.py @@ -0,0 +1,225 @@ +"""Tests for the Excel/CSV merge pipeline.""" +import os +import subprocess +import sys +from pathlib import Path + +import pandas as pd +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from create_samples import create_samples # noqa: E402 +from excel_merger import ( # noqa: E402 + COLUMN_ALIASES, + clean_string, + dedupe_columns, + merge_files, + normalize_column_name, + normalize_currency, + normalize_dataframe, +) + + +class TestNormalizeCurrency: + @pytest.mark.parametrize("raw,expected", [ + ("$5,400.00", 5400.0), + ("5400", 5400.0), + (" $1,299.50 ", 1299.5), + ("-250.75", -250.75), + (3100.0, 3100.0), + ]) + def test_parses_currency_forms(self, raw, expected): + assert normalize_currency(raw) == expected + + @pytest.mark.parametrize("raw", ["", "N/A", None, float("nan")]) + def test_unparseable_returns_none(self, raw): + assert normalize_currency(raw) is None + + def test_rejects_series_with_clear_message(self): + """Guards the duplicate-column failure mode explicitly.""" + with pytest.raises(TypeError, match="duplicate column names"): + normalize_currency(pd.Series([1, 2])) + + +class TestNormalizeColumnName: + @pytest.mark.parametrize("raw,expected", [ + ("Sales Amount", "revenue"), + (" TERRITORY ", "region"), + ("Transaction Date", "date"), + ("Dept", "department"), + ("Unmapped Column", "unmapped column"), + ]) + def test_aliases_and_normalization(self, raw, expected): + assert normalize_column_name(raw) == expected + + def test_alias_map_is_idempotent(self): + """Canonical names must map to themselves, or merging is unstable.""" + for canonical in set(COLUMN_ALIASES.values()): + assert normalize_column_name(canonical) == canonical + + +class TestDedupeColumns: + def test_disambiguates_aliased_collisions(self): + # "Amount" and "Total" both alias to "revenue" + assert dedupe_columns(["name", "revenue", "revenue"], "f.csv") == [ + "name", "revenue", "revenue_2", + ] + + def test_leaves_unique_columns_untouched(self): + cols = ["name", "email", "revenue"] + assert dedupe_columns(cols, "f.csv") == cols + + def test_handles_triple_collision(self): + assert dedupe_columns(["r", "r", "r"], "f.csv") == ["r", "r_2", "r_3"] + + def test_generated_name_cannot_collide_with_an_existing_name(self): + cols = dedupe_columns(["x", "x", "x_2"], "f.csv") + assert cols == ["x", "x_2", "x_2_2"] + assert len(cols) == len(set(cols)) + + +class TestNormalizeDataframe: + def test_collision_does_not_raise(self): + """Regression: previously raised 'truth value of a Series is ambiguous'.""" + df = pd.DataFrame({"Name": ["a"], "Amount": ["$10"], "Total": ["$20"]}) + out = normalize_dataframe(df, "collide.csv") + assert out["revenue"].iloc[0] == 10.0 + assert "revenue_2" in out.columns + + def test_strips_whitespace_and_tags_source(self): + df = pd.DataFrame({"Name": [" Sam Turner "], "Amount": ["$5,400.00"]}) + out = normalize_dataframe(df, "leads.csv") + assert out["name"].iloc[0] == "Sam Turner" + assert out["revenue"].iloc[0] == 5400.0 + assert out["source_file"].iloc[0] == "leads.csv" + + def test_drops_fully_empty_rows(self): + df = pd.DataFrame({"Name": ["a", None], "Amount": ["$1", None]}) + assert len(normalize_dataframe(df, "f.csv")) == 1 + + def test_drops_rows_that_are_empty_after_trimming(self): + df = pd.DataFrame({ + "Name": [" ", None, " real "], + "Email": [None, "\t", ""], + }) + + out = normalize_dataframe(df, "f.csv") + + assert len(out) == 1 + assert out["name"].iloc[0] == "real" + assert out["email"].iloc[0] == "" + + def test_preserves_an_input_source_file_column(self): + df = pd.DataFrame({"source_file": ["user value"], "Name": ["a"]}) + out = normalize_dataframe(df, "actual.csv") + + assert out["source_file_2"].iloc[0] == "user value" + assert out["source_file"].iloc[0] == "actual.csv" + assert list(df.columns) == ["source_file", "Name"] + + +class TestMergeFiles: + def test_merges_dedups_and_writes_both_sheets(self, tmp_path): + src = tmp_path / "in" + src.mkdir() + pd.DataFrame({"Name": ["a", "b"], "Sales Amount": ["$1", "$2"]}).to_csv( + src / "one.csv", index=False) + pd.DataFrame({"name": ["b", "c"], "amount": ["$2", "$3"]}).to_csv( + src / "two.csv", index=False) + + out = tmp_path / "master.xlsx" + merge_files(src, out, deduplicate=True) + + assert out.exists() + sheets = pd.read_excel(out, sheet_name=None) + assert set(sheets) == {"Merged Data", "Merge Summary"} + merged = sheets["Merged Data"] + assert len(merged) == 3 # b/$2 deduplicated across files + assert list(merged.columns)[-1] == "source_file" + + def test_no_dedup_keeps_duplicates(self, tmp_path): + src = tmp_path / "in" + src.mkdir() + for n in ("one.csv", "two.csv"): + pd.DataFrame({"Name": ["a"], "Amount": ["$1"]}).to_csv(src / n, index=False) + out = tmp_path / "m.xlsx" + merge_files(src, out, deduplicate=False) + assert len(pd.read_excel(out, sheet_name="Merged Data")) == 2 + + def test_empty_folder_raises(self, tmp_path): + empty = tmp_path / "empty" + empty.mkdir() + with pytest.raises(FileNotFoundError): + merge_files(empty, tmp_path / "o.xlsx") + + def test_finds_nested_files_and_tracks_relative_source(self, tmp_path): + src = tmp_path / "in" + nested = src / "north" + nested.mkdir(parents=True) + pd.DataFrame({"Name": ["a"]}).to_csv(nested / "DATA.CSV", index=False) + + out = tmp_path / "master.xlsx" + merge_files(src, out) + + merged = pd.read_excel(out, sheet_name="Merged Data") + assert merged["source_file"].tolist() == ["north/DATA.CSV"] + + def test_rerun_does_not_ingest_its_own_output(self, tmp_path): + src = tmp_path / "in" + src.mkdir() + pd.DataFrame({"Name": ["a"]}).to_csv(src / "data.csv", index=False) + out = src / "master.xlsx" + + merge_files(src, out) + merge_files(src, out) + + summary = pd.read_excel(out, sheet_name="Merge Summary") + assert "master.xlsx" not in summary["Source File"].astype(str).tolist() + assert len(pd.read_excel(out, sheet_name="Merged Data")) == 1 + + def test_creates_missing_output_directories(self, tmp_path): + src = tmp_path / "in" + src.mkdir() + pd.DataFrame({"Name": ["a"]}).to_csv(src / "data.csv", index=False) + out = tmp_path / "new" / "nested" / "master.xlsx" + + merge_files(src, out) + + assert out.exists() + + +def test_clean_string_passes_through_non_strings(): + assert clean_string(42) == 42 + assert clean_string(" x ") == "x" + + +def test_import_has_no_log_file_side_effect(tmp_path): + env = os.environ.copy() + env["PYTHONPATH"] = str(PROJECT_ROOT) + + subprocess.run( + [sys.executable, "-W", "error", "-c", "import excel_merger"], + cwd=tmp_path, + env=env, + check=True, + capture_output=True, + text=True, + ) + + assert not (tmp_path / "merger.log").exists() + + +def test_sample_generator_creates_a_missing_destination(tmp_path): + destination = tmp_path / "new" / "sample_input" + + create_samples(destination) + + assert {path.name for path in destination.iterdir()} == { + "hr_employees.csv", + "marketing_leads.csv", + "ops_data.xlsx", + "sales_q1.xlsx", + "sales_q2.xlsx", + }