Files
jorgecuadros-platform/migration/extract.py
T
rmancinasandClaude Opus 4.8 dc9866c24a 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>
2026-07-22 17:15:02 -07:00

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