Run scripted batch work over many spreadsheet files with Python: merge, split, clean, and convert a folder of .xlsx and .csv files, apply the same transform to every file, and reconcile row counts and totals before and after. Use when the same operation must run over many workbooks or files, repeatably, with a per-file log.
./install.sh spreadsheet-automationname: spreadsheet_automation description: "Run scripted batch work over many spreadsheet files with Python: merge, split, clean, and convert a folder of .xlsx and .csv files, apply the same transform to every file, and reconcile row counts and totals before and after. Use when the same operation must run over many workbooks or files, repeatably, with a per-file log." metadata: { "includeInPrompt": true }
Do spreadsheet work as a script that runs over files, so the same job can run over 4 files or 400 files, run again next week with the same result, and prove its own output with row counts and totals. This skill covers the batch layer: merging a folder of workbooks into one file, splitting one workbook into parts, converting between .xlsx and .csv, cleaning values the same way in every file, and logging what happened to each file.
Scope:
amount), and any column that must stay unique across the merged output, such as an id.python3 -c "import openpyxl; print(openpyxl.__version__)"ValueError: numpy.dtype size changed, may indicate binary incompatibility. That is a property of that machine's install, recorded here so the failure is not rediscovered mid-job. The workflow in this skill uses openpyxl plus the Python csv module from the standard library, which covers merge, split, clean, and convert work without pandas.python3 --version (3.12.3 on the drafting machine).~$) and hidden files. The script skips both. A ~$ file in the folder usually means the workbook is open somewhere; close it before the run so the saved file on disk is the current one.csv module returns every field as a string, so a merged output can hold an integer id from one file and a string id from another unless the script converts fields on purpose. In the verified failure fixture in the Operating Rules section, the merged output held 1 (number, from .xlsx) and '12' (string, from .csv) in the same id column. Decide in the job note whether that matters for the job; if ids must compare equal across formats, convert them in the cleaning step.batch_merge.py, next to the job, not inside the input folder.EXPECTED (the header, in order) and AMOUNT_COL (the index of the totals column inside EXPECTED). If the job header has more columns, extend EXPECTED and the cleaning rules together, and re-run the fixture in the Operating Rules before running real files.python3 batch_merge.py INPUT_DIR OUTPUT.xlsx, redirecting output to a log file when the job needs a record: python3 batch_merge.py INPUT_DIR OUTPUT.xlsx > batch_log.txt 2>&1OK or ERROR line per input file, in sorted filename order, then the batch totals. A file with no line was never seen: check the extension filter and the folder path before assuming the file was empty.rows_in and total_in are summed from the files as they were read. The script then reopens the written output file and counts rows_out and total_out from the file on disk, not from memory. reconcile: PASS means the output file holds exactly the rows and total the inputs produced, within half a cent on the total. Reopening the output is the point: a count taken from the in-memory rows would pass even if the save had failed or written a different file.decimal module for that job.ERROR line and every bad_rows count as work left to do, not as noise.bad_rows=N. Open the source, look at the rows whose amount does not parse, and decide whether they are data errors to fix or a format the cleaning rules should accept. Change the rules in the script, not the output.split_rows.py and adapt the chunk size and the grouping rule.python
#!/usr/bin/env python3
"""Split a workbook into chunks of N data rows. Stdlib + openpyxl only."""
import sys
from pathlib import Path
from openpyxl import Workbook, load_workbook
src, out_dir, n = Path(sys.argv[1]), Path(sys.argv[2]), int(sys.argv[3])
wb = load_workbook(src, data_only=True)
rows = list(wb.active.iter_rows(values_only=True))
wb.close()
header, body = rows[0], rows[1:]
out_dir.mkdir(parents=True, exist_ok=True)
for i in range(0, len(body), n):
out = Workbook()
ows = out.active
ows.append(header)
for r in body[i:i + n]:
ows.append(r)
out.save(out_dir / f"part_{i // n + 1}.xlsx")
print(f"source rows: {len(body)}")
Run: python3 split_rows.py merged.xlsx parts 5
csv module, writing an empty string for None cells so empty cells stay empty fields instead of the text None. Verified on the drafting machine against the fixture file a.xlsx, producing a header plus its 3 data rows.python
#!/usr/bin/env python3
"""Convert the first sheet of an .xlsx file to .csv. Stdlib + openpyxl only."""
import csv, sys
from openpyxl import load_workbook
wb = load_workbook(sys.argv[1], data_only=True, read_only=True)
ws = wb.active
with open(sys.argv[2], "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
for row in ws.iter_rows(values_only=True):
w.writerow(["" if c is None else c for c in row])
wb.close()
Run: python3 xlsx_to_csv.py input.xlsx output.csv
csv module and write with openpyxl. The batch merge script already does this for every .csv input, so a folder-level convert can run the merge and then split by source if separate outputs are needed. Remember the type difference from step 3: values read from .csv arrive as strings, so a converted workbook holds text where the original held numbers unless the conversion parses numbers and dates on purpose.name_clean.xlsx in a sibling cleaned folder, so a rerun overwrites its own previous outputs and nothing else.EXPECTED value.reconcile: PASS both times.read_only=True and data_only=True and never saves back to an input path. If a job needs modified inputs, write the modified files as a new batch output and keep the originals.Deliver these items for a batch job:
OK or ERROR line per input file with its row count and total, then files_ok, files_failed, rows_in, total_in, rows_out, total_out, and the reconcile line, plus the run's exit code. On the worked fixture in the Operating Rules section the reconciliation values are: files_ok 4, files_failed 0, rows_in 11, total_in 84.25, rows_out 11, total_out 84.25, reconcile PASS, exit code 0.ERROR and every bad_rows count, each with the cause found and the action taken (fixed at source, deliberately excluded, or still open). An empty failure list is stated as empty, not omitted.csv module and does not depend on pandas. On a different machine the same import check decides; do not assume either library works until it has been imported.data_only=True returns the value a spreadsheet app last calculated and cached in the file; a formula cell in a file that no spreadsheet app has calculated holds no cached value and reads back as None. Verified on the drafting machine: a cell written as =SUM(A1:A2) by openpyxl read back as None with data_only=True and as the formula string =SUM(A1:A2) without it. Totals over a column of formulas are therefore only as good as the cached values in the source files; when totals matter, confirm the sources were calculated and saved by a spreadsheet app first, or compute the total from the underlying value columns in the script.batch_merge.py, prepare a folder of inputs in the format shown under it, then run it. Report counts from its output, not from a hand count.Input format: a folder of .xlsx and .csv files. Every file has the header id,item,amount in row 1 (case-insensitive, surrounding spaces ignored). For .xlsx files only the active sheet is read. Amounts in the worked fixture are multiples of 0.25, so the totals below are exact.
Worked fixture (the files used for the expected output below):
Run: python3 batch_merge.py fixture merged.xlsx
Expected output for that fixture (reproduced exactly on the drafting machine, twice in a row, exit code 0 both times):
text
OK a.xlsx: rows=3 total=22.75
OK b.xlsx: rows=2 total=15.50
OK c.xlsx: rows=4 total=39.00
OK d.csv: rows=2 total=7.00
files_ok: 4
files_failed: 0
rows_in: 11
total_in: 84.25
rows_out: 11
total_out: 84.25
reconcile: PASS
Failure behaviour, also reproduced on the drafting machine. The folder held a.xlsx from the fixture, a broken.xlsx containing plain text instead of a workbook, a messy.csv with the right header plus one row with the amount oops, one blank row, and one row with spaces around the item, and a wrong.csv with the header x,y,z. The run exited with code 1:
text
OK a.xlsx: rows=3 total=22.75
ERROR broken.xlsx: File is not a zip file
OK messy.csv: rows=2 total=7.00 bad_rows=1
ERROR wrong.csv: header ['x', 'y', 'z'] does not match expected ['id', 'item', 'amount']
files_ok: 2
files_failed: 2
rows_in: 5
total_in: 29.75
rows_out: 5
total_out: 29.75
reconcile: PASS
The ERROR wording after the filename comes from the library that tried to open the file, so the exact text varies with the failure. The messy.csv line shows the cleaning rules at work: the spaced item was stripped and kept, the unparseable amount was skipped and counted in bad_rows, and the blank row was skipped.
batch_merge.py (Python 3, standard library plus openpyxl). Save this code as batch_merge.py:
python
#!/usr/bin/env python3
"""Merge a folder of .xlsx and .csv files into one workbook. Stdlib + openpyxl only.
Every input file must share the header id,item,amount (case-insensitive,
surrounding spaces ignored). The active worksheet of each .xlsx file is read.
Cleaning: text cells are stripped of surrounding spaces, fully blank rows
are skipped, and a row whose amount does not parse as a number is skipped
and counted as a bad row for that file.
A file that cannot be read, or whose header does not match, is logged as
ERROR and the batch continues with the remaining files.
Usage: python3 batch_merge.py INPUT_DIR OUTPUT.xlsx
Exit codes: 0 = reconcile PASS and no failed files,
1 = reconcile PASS but at least one file failed,
2 = reconcile FAIL, no usable input files, or a usage error.
"""
import csv
import os
import sys
from pathlib import Path
from openpyxl import Workbook, load_workbook
EXPECTED = ["id", "item", "amount"]
AMOUNT_COL = 2 # index of "amount" in EXPECTED
def norm_header(row):
return [str(c).strip().lower() if c is not None else "" for c in row]
def clean_row(row):
out = []
for c in row:
out.append(c.strip() if isinstance(c, str) else c)
return out
def read_xlsx(path):
wb = load_workbook(path, data_only=True, read_only=True)
try:
ws = wb.active
return [tuple(r) for r in ws.iter_rows(values_only=True)]
finally:
wb.close()
def read_csv(path):
with open(path, newline="", encoding="utf-8-sig") as f:
return [tuple(r) for r in csv.reader(f)]
def process_file(path):
"""Return (rows, total, bad_rows). Raise ValueError/OSError on file failure."""
rows_raw = read_xlsx(path) if path.suffix.lower() == ".xlsx" else read_csv(path)
if not rows_raw:
raise ValueError("file is empty, no header row")
header = norm_header(rows_raw[0])
if header != EXPECTED:
raise ValueError(f"header {header} does not match expected {EXPECTED}")
rows, total, bad = [], 0.0, 0
for raw in rows_raw[1:]:
if raw is None or all(c is None or (isinstance(c, str) and c.strip() == "") for c in raw):
continue
if len(raw) != len(EXPECTED):
bad += 1
continue
row = clean_row(raw)
try:
amount = float(row[AMOUNT_COL])
except (TypeError, ValueError):
bad += 1
continue
row[AMOUNT_COL] = amount
rows.append(row)
total += amount
return rows, total, bad
def reconcile(output_path, rows_in, total_in):
wb = load_workbook(output_path, data_only=True, read_only=True)
try:
ws = wb.active
data = [r for r in ws.iter_rows(values_only=True)]
finally:
wb.close()
body = data[1:] if data else []
rows_out = len(body)
total_out = sum(float(r[AMOUNT_COL]) for r in body)
ok = rows_out == rows_in and abs(total_out - total_in) < 0.005
return rows_out, total_out, ok
def main():
if len(sys.argv) != 3:
print("usage: python3 batch_merge.py INPUT_DIR OUTPUT.xlsx", file=sys.stderr)
return 2
in_dir = Path(sys.argv[1])
out_path = Path(sys.argv[2])
if not in_dir.is_dir():
print(f"error: input dir {in_dir} does not exist", file=sys.stderr)
return 2
out_resolved = out_path.resolve()
files = []
for p in sorted(in_dir.iterdir(), key=lambda x: x.name.lower()):
if p.suffix.lower() not in (".xlsx", ".csv"):
continue
if p.name.startswith("~$") or p.name.startswith("."):
continue
if p.is_file() and p.resolve() != out_resolved:
files.append(p)
if not files:
print("error: no .xlsx or .csv files found", file=sys.stderr)
return 2
all_rows, rows_in, total_in = [], 0, 0.0
files_ok, files_failed = 0, 0
for p in files:
try:
rows, total, bad = process_file(p)
except Exception as e:
print(f"ERROR {p.name}: {e}")
files_failed += 1
continue
suffix = f" bad_rows={bad}" if bad else ""
print(f"OK {p.name}: rows={len(rows)} total={total:.2f}{suffix}")
all_rows.extend(rows)
rows_in += len(rows)
total_in += total
files_ok += 1
print(f"files_ok: {files_ok}")
print(f"files_failed: {files_failed}")
print(f"rows_in: {rows_in}")
print(f"total_in: {total_in:.2f}")
if files_ok == 0:
print("reconcile: FAIL (no usable input files)")
return 2
out_path.parent.mkdir(parents=True, exist_ok=True)
wb = Workbook()
ws = wb.active
ws.title = "merged"
ws.append(EXPECTED)
for r in all_rows:
ws.append(r)
tmp_path = out_path.with_suffix(".tmp.xlsx")
wb.save(tmp_path)
os.replace(tmp_path, out_path)
rows_out, total_out, ok = reconcile(out_path, rows_in, total_in)
print(f"rows_out: {rows_out}")
print(f"total_out: {total_out:.2f}")
print(f"reconcile: {'PASS' if ok else 'FAIL'}")
if not ok:
return 2
return 1 if files_failed else 0
if __name__ == "__main__":
raise SystemExit(main())
publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.