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:
+116
-46
@@ -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", "A�O1", "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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user