""" Reads tables out of an Access database via mdbtools. The project originally used pyodbc + the Windows "Microsoft Access Driver (*.mdb, *.accdb)". That driver is Windows-only; after the move to macOS the extraction layer was reworked to shell out to mdbtools (`mdb-tables`, `mdb-export`) instead — install with `brew install mdbtools`. The public interface (connect / list_tables / read_table / sanitize_column_name) is unchanged so load_staging.py and config.py did not need to change. mdbtools has no persistent connection object, so `connect` just returns the file path and the other functions take that "handle". Notes carried over from the pyodbc era: - Some tables have accented column names ("AÑO1" etc.) that broke pyodbc's UTF-16 path. mdb-export emits UTF-8 and reads those cleanly (verified against PROPANO / FALTANTES AGUA). - MULT has a storage-level corrupted/deleted record that made pyodbc abort the whole table (HY109). mdbtools reads pages directly and simply omits deleted records, so no special per-row skip loop is needed here; any warnings mdb-export prints to stderr are logged, not fatal. - LONGBINARY blob columns are exported with `-b strip` (binary content dropped) so blob bytes never pollute the CSV. Document/blob extraction is a separate step (migration plan step 4) with its own tooling. """ from __future__ import annotations import io import re import shutil import subprocess from pathlib import Path import pandas as pd # mdb-export date/datetime format: emit ISO so downstream parsing is unambiguous. _DATE_FMT = "%Y-%m-%d" _DATETIME_FMT = "%Y-%m-%d %H:%M:%S" def _require_mdbtools() -> None: if shutil.which("mdb-export") is None or shutil.which("mdb-tables") is None: raise RuntimeError( "mdbtools not found on PATH (need mdb-tables and mdb-export). " "Install with: brew install mdbtools" ) def connect(path): """No real connection with mdbtools — return the source path as the handle. Kept for interface compatibility with the old pyodbc-based module (and so load_staging.py's connect()/list_tables()/read_table() flow is unchanged). """ _require_mdbtools() p = Path(path) if not p.exists(): raise FileNotFoundError(f"Access file not found: {p}") return p def list_tables(cnxn) -> list[str]: path = Path(cnxn) result = subprocess.run( ["mdb-tables", "-1", str(path)], capture_output=True, text=True, encoding="utf-8", errors="replace", check=True, ) tables = [] for name in result.stdout.splitlines(): name = name.strip() if not name: continue if name.startswith("MSys") or name.startswith("~"): continue tables.append(name) return sorted(tables) def sanitize_column_name(name: str) -> str: """Access column names are free-form ("NUM id", "AÑO1", "TRUST NUM:"); staging columns need predictable identifiers. The original name is preserved separately as metadata — this is only for the column identifier itself.""" cleaned = re.sub(r"[^0-9a-zA-Z]+", "_", name).strip("_") cleaned = cleaned.lower() if not cleaned: cleaned = "col" if cleaned[0].isdigit(): cleaned = f"c_{cleaned}" return cleaned def read_table(cnxn, table_name: str) -> pd.DataFrame: path = Path(cnxn) # -b strip : drop LONGBINARY/OLE bytes so blobs never corrupt the CSV # (documents are extracted separately in migration step 4). # -D / -T : ISO date/datetime output. # Default comma delimiter + double-quote quoting (doublequote escaping), # which pandas.read_csv parses with its defaults. result = subprocess.run( [ "mdb-export", "-b", "strip", "-D", _DATE_FMT, "-T", _DATETIME_FMT, str(path), table_name, ], capture_output=True, text=True, encoding="utf-8", errors="replace", check=True, ) if result.stderr.strip(): # mdb-export prints warnings (e.g. about odd/deleted records) to # stderr but still exits 0 — surface them without failing the table. for line in result.stderr.splitlines(): if line.strip(): print(f" [mdb-export warn] {table_name}: {line.strip()}") csv_text = result.stdout if not csv_text.strip(): # Empty table: mdb-export still emits a header row, but guard anyway. return pd.DataFrame() # Staging is a 1:1 audit copy — read everything as text (dtype=str) and # treat only an empty field as null. keep_default_na=False is deliberate: # real data can contain literal "NA"/"NULL"/"None" strings that pandas # would otherwise silently turn into missing values. df = pd.read_csv( io.StringIO(csv_text), dtype=str, na_values=[""], keep_default_na=False, low_memory=False, ) original_columns = list(df.columns) # Track original -> sanitized name mapping for the loader; dedupe any # collisions sanitization could introduce (e.g. "NUM id" and "NUM_id" # both -> "num_id"). seen: dict[str, int] = {} sanitized = [] for col in original_columns: base = sanitize_column_name(col) if base in seen: seen[base] += 1 base = f"{base}_{seen[base]}" else: seen[base] = 0 sanitized.append(base) df.columns = sanitized df.attrs["original_columns"] = original_columns return df