Skip to content

Commit 91394a3

Browse files
akarivclaude
andcommitted
Add psql \copy Postgres push flow, CLI support, and a manual-trigger GH Action
Replaces the dataflows-based dump_to_sql call with a simpler psql \copy of the already-built CSV file - matches how a human would push this by hand, and decouples the Postgres push from re-serializing the whole dataset a second way. Truncates the target table first by default (matching the old dump_to_sql(mode='rewrite') semantics), or --no-truncate to append. New CLI: `lamas push-postgres [--csv PATH] [--table T] [--truncate/ --no-truncate]`, usable standalone or via `lamas build --output local,postgres`. Verified end-to-end against a disposable smoke-test table on the real database (created, copied 5 rows in, verified, dropped) before wiring anything to the production table. Added .github/workflows/push-postgres.yml - workflow_dispatch only (never runs on push/PR), requires typing "PUSH" to confirm, runs the full QA suite before pushing. Uses the DATAFLOWS_DB_ENGINE repo secret (set via `gh secret set`, never written to any file in this repo). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent cd25c59 commit 91394a3

5 files changed

Lines changed: 186 additions & 21 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: Push to Postgres
2+
3+
# Manual trigger ONLY - this truncates and overwrites the production lamas_muni table. It must
4+
# never run automatically on push/PR; every other workflow in this repo pushes nothing.
5+
on:
6+
workflow_dispatch:
7+
inputs:
8+
confirm:
9+
description: 'Type PUSH to confirm this will TRUNCATE and overwrite the production lamas_muni table'
10+
required: true
11+
12+
jobs:
13+
push:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Confirm intent
17+
run: |
18+
if [ "${{ github.event.inputs.confirm }}" != "PUSH" ]; then
19+
echo "::error::Confirmation input must be exactly 'PUSH' - aborting."
20+
exit 1
21+
fi
22+
23+
- uses: actions/checkout@v4
24+
25+
- uses: actions/setup-python@v5
26+
with:
27+
python-version: '3.12'
28+
29+
- name: Ensure psql is available
30+
run: command -v psql || (sudo apt-get update && sudo apt-get install -y postgresql-client)
31+
32+
- name: Install lamas package
33+
run: pip install -e "Lamas/[dev]"
34+
35+
# Same cache keys as lamas-tests.yml (repo-scoped, so this reuses whatever it already
36+
# populated) - see that workflow for the rationale.
37+
- name: Cache downloaded workbooks
38+
uses: actions/cache@v4
39+
with:
40+
path: Lamas/downloads
41+
key: lamas-downloads-v1
42+
43+
- name: Download all configured years
44+
run: lamas download
45+
continue-on-error: true
46+
47+
- name: Cache preprocessed checkpoint
48+
uses: actions/cache@v4
49+
with:
50+
path: Lamas/.cache
51+
key: lamas-checkpoint-${{ hashFiles('Lamas/downloads/**', 'Lamas/lamas/preprocess.py', 'Lamas/lamas/sheet_config.py', 'Lamas/data/sheet_config.yaml') }}
52+
53+
- name: Ensure data is available (checkpoint or fresh download)
54+
run: |
55+
if [ -f Lamas/.cache/preprocessed.parquet ]; then
56+
echo "Using cached checkpoint"
57+
elif [ -n "$(ls -A Lamas/downloads 2>/dev/null)" ]; then
58+
lamas preprocess
59+
else
60+
echo "::error::No downloaded workbooks and no cached checkpoint - cannot build"
61+
exit 1
62+
fi
63+
64+
- name: Regenerate pending-headers report
65+
run: lamas map-headers
66+
67+
- name: Run the full QA suite before pushing
68+
run: pytest Lamas/tests/ -v
69+
70+
- name: Build (local output, strict - refuses if any header is unresolved)
71+
run: lamas build --strict --output local
72+
73+
- name: Push to Postgres
74+
env:
75+
DATAFLOWS_DB_ENGINE: ${{ secrets.DATAFLOWS_DB_ENGINE }}
76+
run: lamas push-postgres

Lamas/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,28 @@ drop into just the step you need while iterating on a new year.
4343
| `lamas mapping confirm-fuzzy --orig "..."` | Accept the fuzzy-match suggestion already recorded for a pending row in `pending_headers.csv`. |
4444
| `lamas mapping reject-fuzzy --orig "..." [--canonical "..."]` | Override a fuzzy suggestion: map to a different canonical, or omit `--canonical` to make it a brand-new one. |
4545
| `lamas stats [--year Y]` | Regenerate `reports/header_stats.{csv,md}` - per-header row counts and year coverage, the local replacement for eyeballing the old Airtable "Stats" table. |
46-
| `lamas build [--strict] [--output local,postgres] [--output-dir PATH]` | Apply `specific_fixes`/`value_fixes` and write the final output. `--strict` refuses to build while any header is unresolved. Defaults to local-only output. |
46+
| `lamas build [--strict] [--output local,postgres] [--output-dir PATH]` | Apply `specific_fixes`/`value_fixes` and write the final output. `--strict` refuses to build while any header is unresolved. Defaults to local-only output; `postgres` pushes the resulting CSV via `push-postgres` below. |
47+
| `lamas push-postgres [--csv PATH] [--table lamas_muni] [--truncate/--no-truncate]` | Push an already-built CSV (default `db_bkp/res_1.csv`) into Postgres via `psql \copy`. Reads the connection string from the `DATAFLOWS_DB_ENGINE` env var. Truncates the table first by default (matching a full-replace, not an incremental append) - a destructive, shared-system write, never run automatically. |
4748
| `lamas full-run [--year Y] [--strict] [--output ...]` | `download` -> `preprocess` -> `map-headers` -> `stats` -> `build`, one shot. |
4849
| `lamas qa` | Runs the full pytest suite under `tests/` - the automated quality gate (see below). |
4950

5051
Run `lamas <command> --help` for the full flag list on anything above.
5152

53+
### Pushing to Postgres
54+
55+
`DATAFLOWS_DB_ENGINE` is not auto-loaded from `.env` (nothing in the package calls
56+
`load_dotenv()`) - export it into your shell first:
57+
```bash
58+
set -a; source Lamas/.env; set +a
59+
```
60+
Then either let `build` push it as part of the pipeline (`lamas build --strict --output local,postgres`),
61+
or push a CSV you already have on hand directly:
62+
```bash
63+
lamas push-postgres --csv db_bkp/res_1.csv
64+
```
65+
A manually-triggered GitHub Action (`.github/workflows/push-postgres.yml`) runs this same flow
66+
using a repo secret - see that workflow file for details. It is never triggered automatically.
67+
5268
### Quality assurance
5369

5470
`lamas qa` (or plain `pytest tests/`) runs the same checks a human used to do by eye in Airtable's

Lamas/lamas/cli.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from .headers.specific_fixes import specific_fixes
1616
from .headers.value_fixes import value_fixes
1717
from .io.local import write_local
18-
from .io.postgres import PostgresNotConfiguredError, write_postgres
18+
from .io.postgres import PostgresNotConfiguredError, push_csv_to_postgres
1919
from .logging_config import setup_logging
2020
from .sheet_config import SheetConfig
2121
from .stats import compute_header_stats, write_stats_report
@@ -299,15 +299,36 @@ def build(checkpoint, mapping_path, strict, output, output_dir):
299299
f'{len(unresolved)} unresolved headers remain - refusing to build in --strict mode'
300300
)
301301
targets = [t.strip() for t in output.split(',') if t.strip()]
302-
if 'local' in targets:
302+
# Postgres is pushed from the local CSV (via psql \copy - see push_csv_to_postgres), so the
303+
# local write always happens first when postgres is requested, even if 'local' wasn't asked
304+
# for explicitly.
305+
if 'local' in targets or 'postgres' in targets:
303306
write_local(resolved_rows, output_dir)
304307
click.echo(f'Wrote local output to {output_dir}')
305308
if 'postgres' in targets:
309+
csv_path = Path(output_dir) / 'res_1.csv'
306310
try:
307-
write_postgres(resolved_rows)
311+
push_csv_to_postgres(csv_path)
308312
except PostgresNotConfiguredError as e:
309313
raise click.ClickException(str(e))
310-
click.echo('Wrote to Postgres table lamas_muni')
314+
click.echo('Pushed to Postgres table lamas_muni')
315+
316+
317+
@main.command('push-postgres')
318+
@click.option('--csv', 'csv_path', type=click.Path(exists=True), default=str(DEFAULT_OUTPUT_DIR / 'res_1.csv'))
319+
@click.option('--table', default='lamas_muni')
320+
@click.option('--truncate/--no-truncate', default=True, help='Truncate the table before copying in (default: on).')
321+
def push_postgres(csv_path, table, truncate):
322+
"""Push an already-built CSV file (see `lamas build --output local`) into Postgres via `psql \\copy`.
323+
324+
Reads the connection string from the DATAFLOWS_DB_ENGINE environment variable. This is a
325+
shared-system, destructive write (truncates the table by default) - never run automatically.
326+
"""
327+
try:
328+
push_csv_to_postgres(csv_path, table=table, truncate=truncate)
329+
except PostgresNotConfiguredError as e:
330+
raise click.ClickException(str(e))
331+
click.echo(f'Pushed {csv_path} -> Postgres table {table!r}')
311332

312333

313334
@main.command('full-run')

Lamas/lamas/io/postgres.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,30 @@
11
import os
2+
import re
3+
import subprocess
4+
from pathlib import Path
25

3-
import dataflows as DF
6+
TABLE_IDENTIFIER = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
47

58

69
class PostgresNotConfiguredError(Exception):
710
pass
811

912

10-
def write_postgres(rows, table='lamas_muni', mode='rewrite'):
11-
"""Write the final dataset to Postgres. This formalizes the DF.dump_to_sql(...) call that was
12-
commented out in the original notebook - it is only ever invoked when the user explicitly
13-
asks for `--output postgres`, never by default."""
14-
engine = os.environ.get('DATAFLOWS_DB_ENGINE')
13+
def push_csv_to_postgres(csv_path, table='lamas_muni', truncate=True, engine=None):
14+
"""Push an already-built CSV file (see `write_local` / `lamas build --output local`) into
15+
Postgres via `psql \\copy` - simpler than staging the whole dataset through dataflows'
16+
dump_to_sql, and matches how a human would do this by hand. `truncate=True` (the default)
17+
replaces the table's contents, matching the previous dump_to_sql(mode='rewrite') behavior -
18+
otherwise re-running this would just keep appending duplicate rows."""
19+
if not TABLE_IDENTIFIER.match(table):
20+
raise ValueError(f'Invalid table name: {table!r}')
21+
engine = engine or os.environ.get('DATAFLOWS_DB_ENGINE')
1522
if not engine:
1623
raise PostgresNotConfiguredError('DATAFLOWS_DB_ENGINE is not set - cannot write to Postgres')
17-
DF.Flow(
18-
rows,
19-
DF.update_resource(-1, name='lamas'),
20-
DF.dump_to_sql({
21-
table: {
22-
'resource-name': 'lamas',
23-
'mode': mode,
24-
}
25-
}, engine=engine, batch_size=1000),
26-
).process()
24+
csv_path = str(Path(csv_path).resolve())
25+
escaped_path = csv_path.replace("'", "''")
26+
commands = []
27+
if truncate:
28+
commands += ['-c', f'TRUNCATE TABLE {table};']
29+
commands += ['-c', f"\\copy {table} FROM '{escaped_path}' WITH (FORMAT csv, HEADER true)"]
30+
subprocess.run(['psql', engine, '-v', 'ON_ERROR_STOP=1', *commands], check=True)

Lamas/tests/test_postgres.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import pytest
2+
3+
from lamas.io.postgres import PostgresNotConfiguredError, push_csv_to_postgres
4+
5+
6+
def test_raises_when_engine_not_configured(monkeypatch, tmp_path):
7+
monkeypatch.delenv('DATAFLOWS_DB_ENGINE', raising=False)
8+
csv_path = tmp_path / 'res_1.csv'
9+
csv_path.write_text('year,name\n2024,foo\n')
10+
with pytest.raises(PostgresNotConfiguredError):
11+
push_csv_to_postgres(str(csv_path))
12+
13+
14+
def test_rejects_unsafe_table_name(tmp_path):
15+
csv_path = tmp_path / 'res_1.csv'
16+
csv_path.write_text('year,name\n2024,foo\n')
17+
with pytest.raises(ValueError):
18+
push_csv_to_postgres(str(csv_path), table='lamas_muni; DROP TABLE foo;', engine='postgresql://x')
19+
20+
21+
def test_invokes_psql_with_truncate_and_copy(monkeypatch, tmp_path):
22+
csv_path = tmp_path / 'res_1.csv'
23+
csv_path.write_text('year,name\n2024,foo\n')
24+
calls = []
25+
monkeypatch.setattr('lamas.io.postgres.subprocess.run', lambda args, **kw: calls.append(args))
26+
27+
push_csv_to_postgres(str(csv_path), table='lamas_muni', engine='postgresql://x')
28+
29+
assert calls
30+
args = calls[0]
31+
assert args[0] == 'psql'
32+
assert args[1] == 'postgresql://x'
33+
joined = ' '.join(args)
34+
assert 'TRUNCATE TABLE lamas_muni;' in joined
35+
assert f"\\copy lamas_muni FROM '{csv_path.resolve()}' WITH (FORMAT csv, HEADER true)" in joined
36+
37+
38+
def test_no_truncate_when_disabled(monkeypatch, tmp_path):
39+
csv_path = tmp_path / 'res_1.csv'
40+
csv_path.write_text('year,name\n2024,foo\n')
41+
calls = []
42+
monkeypatch.setattr('lamas.io.postgres.subprocess.run', lambda args, **kw: calls.append(args))
43+
44+
push_csv_to_postgres(str(csv_path), table='lamas_muni', truncate=False, engine='postgresql://x')
45+
46+
joined = ' '.join(calls[0])
47+
assert 'TRUNCATE' not in joined
48+
assert '\\copy lamas_muni FROM' in joined

0 commit comments

Comments
 (0)