#!/usr/bin/env python3 """ batch.py - Robust folder batch driver for the xls-to-csv skill. Converts every workbook in a folder by invoking convert.py once PER FILE in its own subprocess, guarded by a per-file timeout. If a single workbook makes Excel hang, that file is killed and skipped instead of blocking the whole batch. Each convert.py call performs the source-file move on success, so a killed file is left in place for a later retry. Progress is written to --log (flushed per line); a JSON summary to --result. Usage: python batch.py --input-dir "" --out "" \ --processed-dir "/Processed" \ --log "" --result "" [--timeout 300] [--skip-existing] Safety: records pre-existing EXCEL.EXE PIDs before each file and, on timeout, kills ONLY the instance spawned for that file (never the user's open Excel). """ import argparse import csv import datetime import io import json import os import subprocess import sys import time EXTS = (".xlsx", ".xls", ".xlsm", ".xlsb") def excel_pids(): try: out = subprocess.run( ["tasklist", "/FI", "IMAGENAME eq EXCEL.EXE", "/FO", "CSV", "/NH"], capture_output=True, text=True, timeout=30).stdout except Exception: return set() pids = set() for row in csv.reader(io.StringIO(out)): if len(row) >= 2 and row[1].isdigit(): pids.add(int(row[1])) return pids def kill_pid(pid): try: subprocess.run(["taskkill", "/PID", str(pid), "/F", "/T"], capture_output=True, text=True, timeout=30) except Exception: pass def main(): ap = argparse.ArgumentParser(description="Robust folder batch driver for xls-to-csv.") ap.add_argument("--input-dir", required=True) ap.add_argument("--out", required=True) ap.add_argument("--processed-dir", default="") ap.add_argument("--log", required=True) ap.add_argument("--result", required=True) ap.add_argument("--timeout", type=int, default=300, help="Per-file timeout in seconds.") ap.add_argument("--skip-existing", action="store_true") args = ap.parse_args() convert_py = os.path.join(os.path.dirname(os.path.abspath(__file__)), "convert.py") os.makedirs(args.out, exist_ok=True) # Non-recursive scan; never descend into the Processed folder. files = sorted( f for f in os.listdir(args.input_dir) if f.lower().endswith(EXTS) and not f.startswith("~$") and os.path.isfile(os.path.join(args.input_dir, f))) total = len(files) logf = open(args.log, "w", encoding="utf-8", buffering=1) def log(msg): stamp = datetime.datetime.now().strftime("%H:%M:%S") logf.write("[%s] %s\n" % (stamp, msg)) logf.flush() log("BATCH START: %d files, timeout=%ds" % (total, args.timeout)) log("source: %s" % args.input_dir) log("output: %s" % args.out) if args.processed_dir: log("processed: %s" % args.processed_dir) processed, errors, skipped = [], [], [] for i, name in enumerate(files, start=1): src = os.path.join(args.input_dir, name) primary_csv = os.path.join(args.out, os.path.splitext(name)[0] + ".csv") if args.skip_existing and os.path.exists(primary_csv) and os.path.getsize(primary_csv) > 0: skipped.append(name) log("[%d/%d] skip (exists): %s" % (i, total, name)) continue log("[%d/%d] start: %s" % (i, total, name)) baseline = excel_pids() cmd = [sys.executable, convert_py, "--input", src, "--out", args.out] if args.processed_dir: cmd += ["--processed-dir", args.processed_dir] t0 = time.time() proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) try: out_s, _err_s = proc.communicate(timeout=args.timeout) dt = time.time() - t0 summary = None try: summary = json.loads(out_s) except Exception: summary = None if summary and summary.get("processed"): rec = summary["processed"][0] processed.append({"file": name, "sheets": rec.get("sheets"), "moved": bool(rec.get("moved_to"))}) moved = " -> moved" if rec.get("moved_to") else "" log("[%d/%d] done: %s (%s sheet CSV, %.0fs)%s" % (i, total, name, rec.get("sheets"), dt, moved)) else: msg = "" if summary and summary.get("errors"): msg = summary["errors"][0].get("error", "") errors.append({"file": name, "error": "no output; %s" % msg}) log("[%d/%d] ERROR: %s -> %s" % (i, total, name, msg)) except subprocess.TimeoutExpired: proc.kill() try: proc.communicate(timeout=15) except Exception: pass orphans = excel_pids() - baseline for pid in orphans: kill_pid(pid) errors.append({"file": name, "error": "timeout after %ds" % args.timeout}) log("[%d/%d] TIMEOUT: %s (killed %d orphan Excel proc)" % (i, total, name, len(orphans))) time.sleep(2) summary = { "total": total, "converted": len(processed), "errors": errors, "skipped": skipped, "processed": processed, } with open(args.result, "w", encoding="utf-8") as f: json.dump(summary, f, indent=2) log("BATCH COMPLETE: %d converted, %d errors, %d skipped (of %d)" % (len(processed), len(errors), len(skipped), total)) logf.close() return 0 if __name__ == "__main__": sys.exit(main())