Compare commits

...

2 Commits

Author SHA1 Message Date
7b70b7edc6 adapter updates 2026-09-16 09:13:47 -03:00
74e246e67a dataconvert updates 2026-09-16 09:07:07 -03:00
7 changed files with 117 additions and 34 deletions

View File

@@ -46,6 +46,8 @@ Every key is optional.
- **out_dir, max_rows, schema** are the run settings: the same as `--out-dir`,
`--max-rows` and `--no-schema`, and a flag on the command line wins over the file.
There is no default output directory: without `--out-dir` or an `out_dir` in the
config, the run stops before writing anything.
`out_dir` is relative to the folder the config is in, so `"sample"` beside the data
means `<source>/sample`. Inputs whose configs name different `out_dir`s each get
their own `SCHEMA.md`.
@@ -87,6 +89,7 @@ what pandas read: a starting point, not DDL. `--no-schema` skips the file.
| `dataconvert.py` | command line |
| `config.py` | `dataconvert.json`: layouts, sheet naming and run settings |
| `readers.py` | files, directories, ZIPs and globs into DataFrames |
| `progress.py` | progress lines: which file is being read, and how far a long table has got |
| `sqlgen.py` | DataFrames into INSERT statements, with the row cap |
| `output.py` | file naming and writing |
| `schema.py` | `SCHEMA.md` |

View File

@@ -101,17 +101,16 @@ class Config:
self.out_dir = out_dir
@property
def detects(self):
return self.forced is None and (bool(self.rules) or self.fallback is not None)
def probe_rows(self):
"""How many rows the layouts' marker cells need to be read."""
return max((rule.row for rule in self.rules), default=0)
def layout_for(self, raw_df):
"""The layout for a sheet read without a header, or None for the default read."""
if self.forced is not None:
return None if self.forced.is_default else self.forced
def match(self, probe_df):
"""The first layout whose marker cell is in these top rows, or None."""
for rule in self.rules:
if rule.matches(raw_df):
if rule.matches(probe_df):
return rule.layout
return self.fallback
return None
def settings(self):
"""What the file set, for the line the run prints."""

View File

@@ -28,6 +28,7 @@ import argparse
from pathlib import Path
from output import write_tables
from progress import say
import config as cfg
from readers import expand_inputs, iter_sources
from schema import SchemaReport
@@ -44,7 +45,7 @@ def main():
parser = argparse.ArgumentParser(description="Convert data sources into schema-agnostic SQL seed files.")
parser.add_argument("--input", nargs="+", required=True, help="Input file(s), directory, wildcard pattern(s), or ZIP archive(s)")
parser.add_argument("--out-dir", default=None,
help="Directory where individual .sql files will be written (default: the config's out_dir, else ./seed)")
help="Directory where individual .sql files will be written (default: the config's out_dir; one of the two is required)")
parser.add_argument("--max-rows", type=positive_int, default=None,
help="Write at most N rows per table; the header and SCHEMA.md note the full row count and size (default: the config's max_rows, else all)")
parser.add_argument("--no-schema", action="store_true", help="Do not write SCHEMA.md, whatever the config says")
@@ -81,6 +82,17 @@ def main():
parser.error(str(e))
plan.append((in_path, loaded[found] if found is not None else cfg.Config(forced=forced)))
# No silent default directory. A run whose config was not found would
# otherwise write, unlaid-out, into ./seed wherever it was started, and look
# like it worked. Checked for every input before anything is written.
if not args.out_dir:
for in_path, config in plan:
if config.out_dir is not None:
continue
where = (f"{config.source} sets no out_dir" if config.source is not None
else f"no {cfg.FILENAME} found in its folder")
parser.error(f"no output directory for {in_path} ({where}): pass --out-dir, or set out_dir in the config")
# One report per output directory: inputs whose configs name different
# out_dirs each get their own SCHEMA.md, beside their own .sql files.
reports = {}
@@ -89,14 +101,13 @@ def main():
if config.source is not None and config.source not in announced:
announced.add(config.source)
said = config.settings()
print(f"[dataconvert] config: {config.source}" + (f" ({said})" if said else ""))
say(f"config: {config.source}" + (f" ({said})" if said else ""))
# The command line wins, then the config, then the built-in default.
out_dir = Path(args.out_dir) if args.out_dir else (config.out_dir or Path("seed"))
# The command line wins, then the config.
out_dir = Path(args.out_dir) if args.out_dir else config.out_dir
max_rows = args.max_rows if args.max_rows is not None else config.max_rows
schema = not args.no_schema and config.schema is not False
out_dir.mkdir(parents=True, exist_ok=True)
key = out_dir.resolve()
if key not in reports:
reports[key] = (out_dir, SchemaReport(), set())
@@ -107,11 +118,12 @@ def main():
write_tables(dfs, out_dir, source_name, max_rows, report if schema else None, config.bare_sheet_prefixes)
for out_dir, report, caps in reports.values():
if report.entries:
if report.entries and out_dir.is_dir():
# The sampling note names the cap only when every input here used
# the same one; otherwise each table's row still says what it kept.
cap = next(iter(caps)) if len(caps) == 1 else None
print(f"[dataconvert] Generated: {report.write(out_dir, cap)}")
say(f"writing SCHEMA.md for {len(report.tables())} tables")
say(f"Generated: {report.write(out_dir, cap)}")
if __name__ == "__main__":

View File

@@ -4,6 +4,7 @@ Output: one .sql file per table or sheet, named after it.
from pathlib import Path
from progress import say
from sqlgen import render_table, sanitize_identifier
@@ -29,6 +30,9 @@ def write_tables(dfs: dict, out_dir: Path, source_name: str, max_rows=None, repo
filename = table_filename(raw_name, source_name, len(dfs), bare_prefixes)
sql, total, full_bytes, exact = render_table(df, table, max_rows)
out_file = out_dir / filename
# Created on the first file, so a run that finds nothing to convert
# leaves no empty directory behind.
out_dir.mkdir(parents=True, exist_ok=True)
# Several sources can feed the same table; they accumulate in one file.
mode = "a" if out_file.exists() else "w"
@@ -40,4 +44,4 @@ def write_tables(dfs: dict, out_dir: Path, source_name: str, max_rows=None, repo
report.add(source_name, table, filename, df, total, full_bytes, exact, shown)
suffix = "" if exact else f" ({shown} of {total} rows)"
print(f"[dataconvert] Generated: {out_file}{suffix}")
say(f"Generated: {out_file}{suffix}")

View File

@@ -0,0 +1,39 @@
"""
Progress lines: enough to see that a long run is alive and where it is, not so
many that printing them slows it down.
Every line is flushed, so progress shows up as it happens even when the output
is piped or tee'd into a log.
"""
import time
PREFIX = "[dataconvert]"
def say(message):
print(f"{PREFIX} {message}", flush=True)
def seconds(start):
return f"{time.monotonic() - start:.0f}s"
class Ticker:
"""How far a long loop has got, at most once every `every` seconds."""
# How often the loop asks. Checking the clock on every row would itself be
# a measurable cost on a million-row table.
CHECK = 2000
def __init__(self, label, total, every=10.0):
self.label = label
self.total = total
self.every = every
self.started = self.last = time.monotonic()
def tick(self, done):
now = time.monotonic()
if now - self.last >= self.every:
self.last = now
say(f" {self.label}: {done} of {self.total} rows ({now - self.started:.0f}s)")

View File

@@ -8,11 +8,15 @@ one per spreadsheet or CSV. Nothing here knows about SQL or output files.
import glob
import os
import tempfile
import time
import zipfile
from pathlib import Path
import pandas as pd
from progress import say, seconds
from sqlgen import human_bytes
SUPPORTED = {".csv", ".xlsx", ".xls", ".ods"}
@@ -23,7 +27,7 @@ def expand_inputs(inputs):
if any(c in in_str for c in ["*", "?", "["]):
matched = glob.glob(in_str, recursive=True)
if not matched:
print(f"[Warning] No files matched wildcard pattern: '{in_str}'")
print(f"[Warning] No files matched wildcard pattern: '{in_str}'", flush=True)
paths.extend(Path(m) for m in sorted(matched))
else:
paths.append(Path(in_str))
@@ -34,16 +38,22 @@ def read_sheet(read, config):
"""
One sheet or CSV, laid out as the config says.
With nothing to detect, a plain read, exactly as before. Otherwise the sheet
is read once without a header, checked against the layouts, and when one
matches the header and data rows are sliced out of that same read.
With no layout to apply, a plain read, exactly as before. The layouts'
marker cells are checked on only the top few rows, so a big sheet that
matches none of them is read once, not twice. When a layout applies, the
sheet is read without a header and the header and data rows are sliced out.
"""
if not config.detects and config.forced is None:
return read(), None
raw = read(header=None)
layout = config.layout_for(raw)
layout = None
if config.forced is not None:
layout = None if config.forced.is_default else config.forced
else:
if config.rules:
layout = config.match(read(header=None, nrows=config.probe_rows))
if layout is None:
layout = config.fallback
if layout is None:
return read(), None
raw = read(header=None)
data = raw.iloc[layout.data_row - 1:].copy()
data.columns = [str(c).strip() for c in raw.iloc[layout.header_row - 1]]
return data, layout
@@ -54,26 +64,37 @@ def load_dataframes_from_file(file_path: Path, config) -> dict:
ext = file_path.suffix.lower()
dfs = {}
# Said before the read, not after: a big workbook can take minutes inside
# pandas, and this line is what says which file that is.
say(f"reading {file_path.name} ({human_bytes(file_path.stat().st_size)})")
try:
if ext == ".csv":
dfs[file_path.stem], layout = read_sheet(lambda **kw: pd.read_csv(file_path, **kw), config)
note_layout(file_path.name, None, layout)
start = time.monotonic()
# low_memory=False: by default pandas types a big CSV chunk by
# chunk, so one column can come out as numbers in one chunk and
# text in the next, quoted differently row to row in the SQL. Read
# whole, each column gets one type. Costs memory on huge files.
df, layout = read_sheet(lambda **kw: pd.read_csv(file_path, low_memory=False, **kw), config)
dfs[file_path.stem] = df
say(f" {len(df)} rows{layout_note(layout)} ({seconds(start)})")
elif ext in [".xlsx", ".xls", ".ods"]:
xls = pd.ExcelFile(file_path, engine="odf") if ext == ".ods" else pd.ExcelFile(file_path)
for sheet in xls.sheet_names:
dfs[sheet], layout = read_sheet(
start = time.monotonic()
df, layout = read_sheet(
lambda sheet=sheet, **kw: pd.read_excel(xls, sheet_name=sheet, **kw), config)
note_layout(file_path.name, sheet, layout)
dfs[sheet] = df
say(f" sheet {sheet}: {len(df)} rows{layout_note(layout)} ({seconds(start)})")
except Exception as e:
print(f"[Warning] Could not read '{file_path.name}': {e}")
print(f"[Warning] Could not read '{file_path.name}': {e}", flush=True)
return dfs
def note_layout(file_name, sheet, layout):
if layout is not None:
where = f"{file_name} [{sheet}]" if sheet else file_name
print(f"[dataconvert] {where}: layout '{layout.name}', header row {layout.header_row}, data from row {layout.data_row}")
def layout_note(layout):
if layout is None:
return ""
return f", layout '{layout.name}' (header row {layout.header_row}, data from row {layout.data_row})"
def iter_sources(path: Path, config):

View File

@@ -10,6 +10,8 @@ import re
import pandas as pd
from progress import Ticker
def sanitize_identifier(identifier: str) -> str:
"""Sanitize names for SQL tables, columns, and filenames."""
@@ -45,11 +47,14 @@ def render_table(df: pd.DataFrame, table_name: str, max_rows=None):
shown = df if max_rows is None else df.head(max_rows)
rows = []
ticker = Ticker(table_name, len(shown))
# iterrows, not itertuples: it hands values over the way the original tool
# did, and seed files people already load depend on exactly that quoting.
for _, row in shown.iterrows():
for i, (_, row) in enumerate(shown.iterrows(), 1):
vals = ", ".join(sql_value(v) for v in row)
rows.append(f"INSERT INTO {table_ref} ({cols}) VALUES ({vals}) ON CONFLICT DO NOTHING;\n")
if i % Ticker.CHECK == 0:
ticker.tick(i)
head = f"-- Generated seed data for table: {table_ref}\n"
begin, commit = "BEGIN;\n\n", "\nCOMMIT;\n"