macOS has no Access ODBC driver, so the pyodbc-based extract.py could not run. Rewrite it to shell out to mdbtools (mdb-tables/mdb-export) while keeping the public interface (connect/list_tables/read_table/ sanitize_column_name) unchanged, so load_staging.py and config.py are untouched. connect() now returns the file path as the handle (mdbtools has no persistent connection). Behavior details: - -b strip drops LONGBINARY/OLE bytes so blobs never corrupt the CSV (documents are extracted separately in migration step 4). - ISO date/datetime output (-D/-T); staging read as text (dtype=str), only empty fields treated as null (keep_default_na=False) so literal "NA"/"NULL" data strings survive. - mdbtools reads deleted/corrupted records mdbtools omits rather than aborting, so the old per-row skip loop is no longer needed. Verified end-to-end: full staging load reproduces the original Windows run (82 tables, 0 errors) with exact row counts (datos2 16000, EFECTIVO 13697, DATGRAL 1172/1070, DATMEX 1520) and recovers all 764 MULT rows (the pyodbc path lost 1 to HY109 corruption). Accented-column tables (PROPANO) read cleanly. requirements.txt: drop pyodbc/pywin32 (Windows-only), keep pandas/pyarrow/ sqlalchemy/pymysql; document the Windows-only DAO catalog as historical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
165 lines
5.5 KiB
Python
165 lines
5.5 KiB
Python
"""
|
|
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
|