feat(cms): ingest_log provenance table + writer (refs #594)
This commit is contained in:
128
src/cms/ingest_log.py
Normal file
128
src/cms/ingest_log.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Append-only DuckDB provenance table for ingest operations.
|
||||
|
||||
Complements the JSONL logs in `cms.log`, does not replace them.
|
||||
Tracks which reference tables were ingested, when, and by which module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def new_run_id() -> str:
|
||||
"""Generate a new run ID as a UUID4 hex string."""
|
||||
return uuid4().hex
|
||||
|
||||
|
||||
def file_sha256(path: str | Path) -> str:
|
||||
"""Compute SHA256 hash of a file.
|
||||
|
||||
Args:
|
||||
path: Path to the file to hash.
|
||||
|
||||
Returns:
|
||||
Hexadecimal SHA256 hash of the file contents.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the file does not exist.
|
||||
"""
|
||||
path = Path(path)
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(chunk)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
def ensure_table(con) -> None:
|
||||
"""Create the cms.ingest_log table if it does not exist.
|
||||
|
||||
Args:
|
||||
con: An open DuckDB connection.
|
||||
"""
|
||||
con.execute("""
|
||||
CREATE SCHEMA IF NOT EXISTS cms
|
||||
""")
|
||||
con.execute("""
|
||||
CREATE TABLE IF NOT EXISTS cms.ingest_log (
|
||||
run_id VARCHAR,
|
||||
ingested_at TIMESTAMP,
|
||||
module VARCHAR,
|
||||
table_name VARCHAR,
|
||||
rule_id VARCHAR,
|
||||
source_file VARCHAR,
|
||||
sha256 VARCHAR,
|
||||
rows BIGINT,
|
||||
fr_citation VARCHAR,
|
||||
pincite_key VARCHAR
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def log_ingest(
|
||||
con,
|
||||
*,
|
||||
module: str,
|
||||
table_name: str,
|
||||
rows: int,
|
||||
source_file: str = "",
|
||||
rule_id: str = "",
|
||||
fr_citation: str = "",
|
||||
pincite_key: str = "",
|
||||
run_id: str = "",
|
||||
) -> None:
|
||||
"""Append an ingest log entry to the provenance table.
|
||||
|
||||
Args:
|
||||
con: An open DuckDB connection.
|
||||
module: Name of the module performing the ingest.
|
||||
table_name: Name of the table being ingested.
|
||||
rows: Number of rows ingested.
|
||||
source_file: Path to the source file (optional). If provided and exists,
|
||||
its SHA256 hash is computed and stored.
|
||||
rule_id: Rule ID associated with this ingest (optional).
|
||||
fr_citation: Federal Register citation (optional).
|
||||
pincite_key: Pincite key (optional).
|
||||
run_id: Run ID for this ingest operation (optional). If not provided,
|
||||
a new one is generated.
|
||||
"""
|
||||
ensure_table(con)
|
||||
|
||||
# Generate run_id if not provided
|
||||
if not run_id:
|
||||
run_id = new_run_id()
|
||||
|
||||
# Compute SHA256 if source_file exists
|
||||
sha256 = ""
|
||||
if source_file:
|
||||
source_path = Path(source_file)
|
||||
if source_path.exists():
|
||||
sha256 = file_sha256(source_path)
|
||||
|
||||
# Get current timestamp in UTC
|
||||
ingested_at = datetime.now(timezone.utc)
|
||||
|
||||
# Insert the row
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO cms.ingest_log (
|
||||
run_id, ingested_at, module, table_name, rule_id,
|
||||
source_file, sha256, rows, fr_citation, pincite_key
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
run_id,
|
||||
ingested_at,
|
||||
module,
|
||||
table_name,
|
||||
rule_id,
|
||||
source_file,
|
||||
sha256,
|
||||
rows,
|
||||
fr_citation,
|
||||
pincite_key,
|
||||
],
|
||||
)
|
||||
34
tests/cms/test_ingest_log.py
Normal file
34
tests/cms/test_ingest_log.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Tests for cms.ingest_log — provenance table writer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import duckdb
|
||||
|
||||
from cms import ingest_log
|
||||
|
||||
|
||||
def test_log_ingest_appends_row(tmp_path):
|
||||
src = tmp_path / "f.csv"
|
||||
src.write_text("a,b\n1,2\n")
|
||||
con = duckdb.connect()
|
||||
ingest_log.log_ingest(
|
||||
con,
|
||||
module="pfs",
|
||||
table_name="pfs.rvu_proposed",
|
||||
rows=2,
|
||||
source_file=str(src),
|
||||
rule_id="CMS-1832-P",
|
||||
fr_citation="90 FR 1",
|
||||
pincite_key="2KVJ2HKX",
|
||||
run_id=ingest_log.new_run_id(),
|
||||
)
|
||||
row = con.execute(
|
||||
"SELECT module, table_name, rows, rule_id, length(sha256) FROM cms.ingest_log"
|
||||
).fetchone()
|
||||
assert row == ("pfs", "pfs.rvu_proposed", 2, "CMS-1832-P", 64)
|
||||
|
||||
|
||||
def test_missing_source_file_logs_empty_hash():
|
||||
con = duckdb.connect()
|
||||
ingest_log.log_ingest(con, module="pfs", table_name="t", rows=0)
|
||||
assert con.execute("SELECT sha256 FROM cms.ingest_log").fetchone()[0] == ""
|
||||
Reference in New Issue
Block a user