An end-to-end audit found the repo could not build, test or run as shipped. This fixes every finding, then adds a Cloud Run track so the demo costs about £1/month idle instead of ~£150. CI (red on its first run) - api: setuptools could not build the package (flat layout with app/ and alembic/) - web: missing @types/node; `vitest run` exited 1 with no test files - pipeline: the stub run needed a gitignored VCF, and no process had a stub block - ruff pinned, mypy configured, DB tests on real Postgres (pgserver locally, service in CI) ML serving (scores were meaningless) - the registered model now carries its own feature engineering and returns predict_proba, so serving sends raw columns and cannot drift from training - resolve by registry alias (stages are deprecated in MLflow 3) and record the real version; re-scoring upserts instead of failing on the unique constraint - ClinVar labels parsed from VEP's lowercase terms Pipeline - exact ref/alt recovered from a CHROM_POS_REF_ALT VCF ID; loading is idempotent - job status reaches running/failed/succeeded, so the UI stops polling dead jobs - DATABASE_URL travels in the environment or a Nextflow secret, never on a command line - VEP cache and plugins staged as inputs; the gcp profile runs tasks on Google Batch Deployment - the API serves /api (matching the ingress); the web app reads its API URL at runtime - migrations run in an init container under a Postgres advisory lock - terraform: custom VPC shared with Batch, private Cloud SQL, API enablement, Workload Identity bindings, Secret Manager, deletion protection - serverless track, now the default: Cloud Run services scaling to zero, a Cloud Run job for the Nextflow driver, and Neon or Cloud SQL behind one DATABASE_URL secret. GKE and Argo remain, behind -var deploy_kubernetes=true. See docs/cloud.md. Correctness and security - 409 on duplicate sample names, 422 on bad paging, natural chromosome ordering, wider VEP text columns, enum dropped on downgrade, the sample's assembly actually used - vcf_uri restricted to gs:// objects or files under the data root, blocking option injection - CORS restricted to configured origins; `make down` no longer deletes volumes Data - docs/data.md records the peer-reviewed, openly licensed sources (GIAB HG002, ClinVar, gnomAD) with citations and an honest evaluation plan; `make data` fetches a chr22 slice Verified: api 50 tests, ml 18, loader 16, web 12; ruff, mypy, svelte-check, terraform validate and both kustomize overlays clean.
136 lines
5.0 KiB
Python
Executable File
136 lines
5.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Load VEP --tab output into the rarelens Postgres schema and mark the job succeeded.
|
|
|
|
Variant identity (chrom/pos/ref/alt) comes from the VCF ID, which NORMALISE sets to
|
|
CHROM_POS_REF_ALT: VEP's own Location/Allele columns trim indel alleles and shift positions.
|
|
The database URL is read from $DATABASE_URL so it never appears on a command line or in .command.sh.
|
|
Loading replaces any rows already stored for the job, so a retried task cannot duplicate variants.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
from sqlalchemy import Engine, create_engine, text
|
|
from sqlalchemy.engine import make_url
|
|
|
|
INSERT_CHUNK = 5000
|
|
VEP_VERSION = re.compile(r"^## ENSEMBL VARIANT EFFECT PREDICTOR v(\S+)")
|
|
|
|
|
|
def engine_for(url: str) -> Engine:
|
|
"""Accept the API's asyncpg URL (or a plain postgresql:// one) and use psycopg."""
|
|
parsed = make_url(url).set(drivername="postgresql+psycopg")
|
|
query = dict(parsed.query)
|
|
if "ssl" in query: # asyncpg's spelling; libpq (psycopg) wants sslmode
|
|
query["sslmode"] = query.pop("ssl")
|
|
return create_engine(parsed.set(query=query))
|
|
|
|
|
|
def read_vep_tab(path: str | Path) -> pd.DataFrame:
|
|
"""Read VEP --tab output as strings, keeping "-" (VEP's missing marker) verbatim."""
|
|
with open(path) as fh:
|
|
for n, line in enumerate(fh):
|
|
if line.startswith("#Uploaded_variation"):
|
|
break
|
|
else:
|
|
raise ValueError(f"{path}: no #Uploaded_variation header; is this VEP --tab output?")
|
|
df = pd.read_csv(path, sep="\t", skiprows=n, dtype=str, keep_default_na=False)
|
|
return df.rename(columns={"#Uploaded_variation": "Uploaded_variation"})
|
|
|
|
|
|
def vep_version(path: str | Path) -> str | None:
|
|
with open(path) as fh:
|
|
for line in fh:
|
|
if not line.startswith("##"):
|
|
return None
|
|
if m := VEP_VERSION.match(line):
|
|
return m.group(1)
|
|
return None
|
|
|
|
|
|
def parse_variant_id(uid: str) -> tuple[str, int, str, str]:
|
|
# rsplit: contig names may contain "_" (chrUn_KI270742v1); positions and alleles never do.
|
|
parts = uid.rsplit("_", 3)
|
|
if len(parts) != 4 or not parts[1].isdigit():
|
|
raise ValueError(
|
|
f"unexpected Uploaded_variation {uid!r}; NORMALISE must set VCF IDs to CHROM_POS_REF_ALT"
|
|
)
|
|
chrom, pos, ref, alt = parts
|
|
return chrom, int(pos), ref, alt
|
|
|
|
|
|
def _value(v: str | None) -> str | None:
|
|
return None if v in (None, "", "-") else v
|
|
|
|
|
|
def to_rows(df: pd.DataFrame, job_id: str) -> list[dict]:
|
|
rows = []
|
|
for rec in df.to_dict("records"):
|
|
chrom, pos, ref, alt = parse_variant_id(rec["Uploaded_variation"])
|
|
af = _value(rec.get("gnomADe_AF"))
|
|
rows.append({
|
|
"job_id": job_id,
|
|
"chrom": chrom,
|
|
"pos": pos,
|
|
"ref": ref,
|
|
"alt": alt,
|
|
"gene": _value(rec.get("SYMBOL")),
|
|
"consequence": _value(rec.get("Consequence")),
|
|
"impact": _value(rec.get("IMPACT")),
|
|
"hgvsc": _value(rec.get("HGVSc")),
|
|
"hgvsp": _value(rec.get("HGVSp")),
|
|
"gnomad_af": float(af) if af is not None else None,
|
|
"clinvar_sig": _value(rec.get("CLIN_SIG")),
|
|
"annotations": json.dumps({k: v for k, v in rec.items() if _value(v) is not None}),
|
|
})
|
|
return rows
|
|
|
|
|
|
def load(engine: Engine, job_id: str, rows: list[dict], vep: str | None) -> None:
|
|
with engine.begin() as conn:
|
|
conn.execute(text("DELETE FROM variants WHERE job_id = :id"), {"id": job_id})
|
|
for start in range(0, len(rows), INSERT_CHUNK):
|
|
conn.execute(
|
|
text("""
|
|
INSERT INTO variants (job_id, chrom, pos, ref, alt, gene, consequence, impact,
|
|
hgvsc, hgvsp, gnomad_af, clinvar_sig, annotations)
|
|
VALUES (:job_id, :chrom, :pos, :ref, :alt, :gene, :consequence, :impact,
|
|
:hgvsc, :hgvsp, :gnomad_af, :clinvar_sig, CAST(:annotations AS jsonb))
|
|
"""),
|
|
rows[start:start + INSERT_CHUNK],
|
|
)
|
|
conn.execute(
|
|
text("""
|
|
UPDATE jobs SET status = 'succeeded', vep_version = :vep, log = NULL,
|
|
finished_at = now()
|
|
WHERE id = :id
|
|
"""),
|
|
{"id": job_id, "vep": vep},
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument("--tsv", required=True)
|
|
p.add_argument("--job-id", required=True)
|
|
p.add_argument("--dry-run", action="store_true", help="parse only; do not touch the database")
|
|
a = p.parse_args()
|
|
|
|
rows = to_rows(read_vep_tab(a.tsv), job_id=a.job_id)
|
|
if a.dry_run:
|
|
print(f"{len(rows)} variants parsed (dry run, no DB)")
|
|
return
|
|
url = os.environ.get("DATABASE_URL")
|
|
if not url:
|
|
sys.exit("DATABASE_URL is not set")
|
|
load(engine_for(url), a.job_id, rows, vep=vep_version(a.tsv))
|
|
print(f"loaded {len(rows)} variants for job {a.job_id}", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|