Port extraction layer from pyodbc to mdbtools for macOS

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>
This commit is contained in:
2026-07-22 17:15:02 -07:00
co-authored by Claude Opus 4.8
parent 22a53bc562
commit dc9866c24a
2 changed files with 124 additions and 48 deletions
+116 -46
View File
@@ -1,32 +1,80 @@
""" """
Reads tables out of an Access database via pyodbc. Reads tables out of an Access database via mdbtools.
Column metadata is read from cursor.description after a SELECT * rather than The project originally used pyodbc + the Windows "Microsoft Access Driver
via cursor.columns() — the latter hits a UTF-16 decode bug in pyodbc/the (*.mdb, *.accdb)". That driver is Windows-only; after the move to macOS the
Access ODBC driver on a subset of tables whose column names contain certain extraction layer was reworked to shell out to mdbtools (`mdb-tables`,
accented characters (confirmed against PROPANO, FALTANTES AGUA, TIT in this `mdb-export`) instead — install with `brew install mdbtools`.
session). SELECT * + description does not hit that path.
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 from __future__ import annotations
import io
import re import re
import pyodbc import shutil
import subprocess
from pathlib import Path
import pandas as pd import pandas as pd
ACCESS_DRIVER = "Microsoft Access Driver (*.mdb, *.accdb)" # 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 connect(path) -> pyodbc.Connection: def _require_mdbtools() -> None:
conn_str = f"DRIVER={{{ACCESS_DRIVER}}};DBQ={path};" if shutil.which("mdb-export") is None or shutil.which("mdb-tables") is None:
return pyodbc.connect(conn_str, autocommit=True) raise RuntimeError(
"mdbtools not found on PATH (need mdb-tables and mdb-export). "
"Install with: brew install mdbtools"
)
def list_tables(cnxn: pyodbc.Connection) -> list[str]: def connect(path):
cursor = cnxn.cursor() """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 = [] tables = []
for row in cursor.tables(tableType="TABLE"): for name in result.stdout.splitlines():
name = row.table_name name = name.strip()
if not name:
continue
if name.startswith("MSys") or name.startswith("~"): if name.startswith("MSys") or name.startswith("~"):
continue continue
tables.append(name) tables.append(name)
@@ -34,10 +82,10 @@ def list_tables(cnxn: pyodbc.Connection) -> list[str]:
def sanitize_column_name(name: str) -> str: def sanitize_column_name(name: str) -> str:
"""Access column names are free-form ("NUM id", "AO1", "TRUST NUM:"); """Access column names are free-form ("NUM id", "AÑO1", "TRUST NUM:");
Postgres staging columns need to be predictable identifiers. Original staging columns need predictable identifiers. The original name is
name is preserved separately as metadata, this is only for the column preserved separately as metadata this is only for the column identifier
identifier itself.""" itself."""
cleaned = re.sub(r"[^0-9a-zA-Z]+", "_", name).strip("_") cleaned = re.sub(r"[^0-9a-zA-Z]+", "_", name).strip("_")
cleaned = cleaned.lower() cleaned = cleaned.lower()
if not cleaned: if not cleaned:
@@ -47,37 +95,59 @@ def sanitize_column_name(name: str) -> str:
return cleaned return cleaned
def read_table(cnxn: pyodbc.Connection, table_name: str) -> pd.DataFrame: def read_table(cnxn, table_name: str) -> pd.DataFrame:
cursor = cnxn.cursor() path = Path(cnxn)
cursor.execute(f"SELECT * FROM [{table_name}]")
original_columns = [d[0] for d in cursor.description]
# fetchall() aborts the whole table on the first bad row. Some legacy # -b strip : drop LONGBINARY/OLE bytes so blobs never corrupt the CSV
# tables (confirmed: MULT) have Jet/ACE-level corruption — a record # (documents are extracted separately in migration step 4).
# marked deleted at the storage level that the driver still enumerates # -D / -T : ISO date/datetime output.
# but can't SQLGetData from ("Record is deleted", HY109). Fetch one row # Default comma delimiter + double-quote quoting (doublequote escaping),
# at a time so a corrupted row is skipped and logged instead of losing # which pandas.read_csv parses with its defaults.
# the entire table. result = subprocess.run(
data = [] [
skipped = 0 "mdb-export",
while True: "-b", "strip",
try: "-D", _DATE_FMT,
row = cursor.fetchone() "-T", _DATETIME_FMT,
except pyodbc.Error as exc: str(path),
skipped += 1 table_name,
print(f" [skip row] {table_name}: {exc}") ],
continue capture_output=True,
if row is None: text=True,
break encoding="utf-8",
data.append(list(row)) errors="replace",
if skipped: check=True,
print(f" [{table_name}] skipped {skipped} corrupted row(s)") )
df = pd.DataFrame(data, columns=original_columns) 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 # Track original -> sanitized name mapping for the loader; dedupe any
# collisions that sanitization could introduce (e.g. "NUM id" and # collisions sanitization could introduce (e.g. "NUM id" and "NUM_id"
# "NUM_id" both -> "num_id"). # both -> "num_id").
seen: dict[str, int] = {} seen: dict[str, int] = {}
sanitized = [] sanitized = []
for col in original_columns: for col in original_columns:
+8 -2
View File
@@ -1,6 +1,12 @@
pyodbc>=5.0 # Extraction on macOS uses mdbtools (brew install mdbtools) via subprocess,
# not pyodbc — no Access ODBC driver exists on macOS. See extract.py.
pandas>=2.2 pandas>=2.2
pyarrow>=15.0 pyarrow>=15.0
sqlalchemy>=2.0 sqlalchemy>=2.0
pymysql>=1.1 pymysql>=1.1
pywin32>=306 # Windows only - needed for catalog_objects.py (DAO COM automation)
# Windows-only, historical — the DAO/COM object catalog (catalog_objects.py)
# was already run on Windows and its output is committed (objects.json). Not
# needed on macOS; left documented for provenance.
# pyodbc>=5.0
# pywin32>=306