Skip to content

Commit 183f4f9

Browse files
committed
Add e2e tests and logic for indexing .vcf
Turns out that bcftools requires bgzipped VCFs for indexing. Currently, DivBase enforces .vcf.gz but if this were to change, the dimensions update logic would suddenly break for .vcf files. This adds bgzipping steps to the VCFDimensionCalculator.
1 parent ca9180a commit 183f4f9

2 files changed

Lines changed: 143 additions & 2 deletions

File tree

packages/divbase-api/src/divbase_api/worker/vcf_dimension_indexing.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,36 @@ def calculate_dimensions(self, vcf_path: Path) -> VCFDimensions | None:
2929
3030
Returns None if this is a DivBase-generated result file (should be skipped).
3131
This is checked for when the header is parsed in _extract_sample_names_from_vcf_header.
32+
33+
Note: bcftools index --csi requires bgzipped input. Plain .vcf files are bgzipped to a
34+
temporary .vcf.gz file for indexing, then the temporary file is cleaned up internally.
3235
"""
3336
logger.debug(f"Reading: {vcf_path} ...")
3437

3538
sample_names = self._extract_sample_names_from_vcf_header(vcf_path)
3639
if sample_names is None:
3740
return None
3841

39-
csi_index_path = self._index_vcf_with_csi(vcf_path)
40-
scaffold_names, variant_count = self._extract_scaffold_names_and_variant_count_from_csi_index(csi_index_path)
42+
indexing_path = vcf_path
43+
44+
if vcf_path.suffix == ".vcf":
45+
bgzipped_temp = Path(str(vcf_path) + ".gz")
46+
self._bgzip_vcf(vcf_path, bgzipped_temp)
47+
indexing_path = bgzipped_temp
48+
49+
try:
50+
csi_index_path = self._index_vcf_with_csi(vcf_path=indexing_path)
51+
scaffold_names, variant_count = self._extract_scaffold_names_and_variant_count_from_csi_index(
52+
csi_index_path=csi_index_path
53+
)
54+
finally:
55+
# Clean up temp bgzipped file and its CSI index; these are not tracked by tasks.py
56+
if bgzipped_temp is not None:
57+
bgzipped_temp_csi = Path(str(bgzipped_temp) + ".csi")
58+
if bgzipped_temp.exists():
59+
bgzipped_temp.unlink()
60+
if bgzipped_temp_csi.exists():
61+
bgzipped_temp_csi.unlink()
4162

4263
return VCFDimensions(
4364
variants=variant_count,
@@ -73,10 +94,27 @@ def _extract_sample_names_from_vcf_header(self, vcf_path: Path) -> List[str] | N
7394
logger.error(f"Error extracting sample names from the VCF header {vcf_path}: {e}")
7495
raise
7596

97+
def _bgzip_vcf(self, vcf_path: Path, output_path: Path) -> None:
98+
"""
99+
Bgzip a plain .vcf file to a bgzipped .vcf.gz file using bcftools view.
100+
Required because bcftools index --csi only accepts bgzipped input.
101+
"""
102+
try:
103+
subprocess.run(
104+
["bcftools", "view", "-Oz", "-o", str(output_path), str(vcf_path)],
105+
check=True,
106+
stderr=subprocess.DEVNULL,
107+
)
108+
logger.info(f"Bgzipped {vcf_path} to {output_path} for CSI indexing.")
109+
except subprocess.CalledProcessError as e:
110+
logger.error(f"Error bgzipping {vcf_path}: {e}")
111+
raise
112+
76113
def _index_vcf_with_csi(self, vcf_path: Path) -> Path:
77114
"""
78115
Index the VCF file with CSI index using bcftools.
79116
The CSI index is then used to extract dimensions information.
117+
Input must be bgzipped (.vcf.gz); use _bgzip_vcf first for plain .vcf files.
80118
"""
81119
csi_index_path = vcf_path.with_suffix(vcf_path.suffix + ".csi")
82120
try:

tests/e2e_integration/cli_commands/test_dimensions_cli.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,3 +890,106 @@ def test_validate_metadata_file_nonexistent(
890890

891891
assert cli_result.exit_code == 2, "Expected exit code 2 for nonexistent file (Typer path validation)"
892892
assert "does not exist" in cli_result.output.lower(), "Expected error message about file not existing"
893+
894+
895+
@patch("divbase_api.worker.tasks.create_s3_file_manager")
896+
def test_update_dimensions_cleans_up_csi_index_files_from_worker(
897+
mock_create_s3_manager,
898+
CONSTANTS,
899+
project_map,
900+
tmp_path,
901+
monkeypatch,
902+
):
903+
"""
904+
Test that CSI index files created during dimension calculation are deleted from the worker
905+
filesystem after update_vcf_dimensions_task completes.
906+
"""
907+
mock_create_s3_manager.side_effect = lambda url=None: create_s3_file_manager(url=CONSTANTS["MINIO_URL"])
908+
monkeypatch.chdir(tmp_path)
909+
910+
project_name = CONSTANTS["SPLIT_SCAFFOLD_PROJECT"]
911+
bucket_name = CONSTANTS["PROJECT_TO_BUCKET_MAP"][project_name]
912+
project_id = project_map[project_name]
913+
user_id = 1
914+
915+
result = update_vcf_dimensions_task(
916+
bucket_name=bucket_name, project_id=project_id, project_name=project_name, user_id=user_id
917+
)
918+
919+
assert result["status"] == "completed"
920+
921+
vcf_files_remaining = list(tmp_path.glob("*.vcf")) + list(tmp_path.glob("*.vcf.gz"))
922+
assert vcf_files_remaining == [], (
923+
f"Expected all VCF files to be deleted from worker after task, but found: {vcf_files_remaining}"
924+
)
925+
926+
csi_files_remaining = list(tmp_path.glob("*.csi"))
927+
assert csi_files_remaining == [], (
928+
f"Expected all CSI index files to be deleted from worker after task, but found: {csi_files_remaining}"
929+
)
930+
931+
932+
@patch("divbase_api.worker.tasks.create_s3_file_manager")
933+
def test_update_dimensions_indexes_uncompressed_vcf(
934+
mock_create_s3_manager,
935+
CONSTANTS,
936+
db_session_sync,
937+
project_map,
938+
tmp_path,
939+
):
940+
"""
941+
Test that update_vcf_dimensions_task correctly indexes a plain uncompressed .vcf file.
942+
943+
bcftools index --csi requires bgzipped input, so calculate_dimensions bgzips plain .vcf
944+
files to a temp .vcf.gz internally before indexing, then cleans up the temp files.
945+
This tests that the full indexing pipeline works end-to-end for uncompressed VCFs.
946+
"""
947+
mock_create_s3_manager.side_effect = lambda url=None: create_s3_file_manager(url=CONSTANTS["MINIO_URL"])
948+
949+
project_name = CONSTANTS["SPLIT_SCAFFOLD_PROJECT"]
950+
bucket_name = CONSTANTS["PROJECT_TO_BUCKET_MAP"][project_name]
951+
project_id = project_map[project_name]
952+
user_id = 1
953+
954+
plain_vcf_name = "test_uncompressed_plain.vcf"
955+
vcf_path = tmp_path / plain_vcf_name
956+
vcf_content = (
957+
"##fileformat=VCFv4.2\n"
958+
"##contig=<ID=1,length=22053058>\n"
959+
"#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tsample1\tsample2\n"
960+
"1\t17504018\t.\tC\tA\t.\tPASS\t.\tGT\t0/1\t0/0\n"
961+
"1\t22053057\t.\tG\tA\t.\tPASS\t.\tGT\t1/1\t0/1\n"
962+
)
963+
vcf_path.write_text(vcf_content)
964+
965+
s3_file_manager = create_s3_file_manager(url=CONSTANTS["MINIO_URL"])
966+
s3_file_manager.upload_files(
967+
to_upload={plain_vcf_name: vcf_path},
968+
bucket_name=bucket_name,
969+
)
970+
971+
try:
972+
result = update_vcf_dimensions_task(
973+
bucket_name=bucket_name, project_id=project_id, project_name=project_name, user_id=user_id
974+
)
975+
976+
assert result["status"] == "completed"
977+
indexed_files = result.get("VCF_files_added") or []
978+
assert plain_vcf_name in indexed_files, f"Expected plain .vcf file to be indexed, got: {indexed_files}"
979+
980+
# Verify the dimensions were stored correctly in the DB
981+
db_session_sync.expire_all()
982+
vcf_dimensions = get_vcf_metadata_by_project(project_id=project_id, db=db_session_sync)
983+
all_indexed_keys = [entry["vcf_file_s3_key"] for entry in vcf_dimensions.get("vcf_files", [])]
984+
assert plain_vcf_name in all_indexed_keys
985+
986+
entry = next(e for e in vcf_dimensions["vcf_files"] if e["vcf_file_s3_key"] == plain_vcf_name)
987+
assert entry["sample_count"] == 2
988+
assert entry["variant_count"] == 2
989+
assert "1" in entry["scaffolds"]
990+
assert "sample1" in entry["samples"]
991+
assert "sample2" in entry["samples"]
992+
finally:
993+
# Remove the uploaded file from the bucket to avoid polluting subsequent tests.
994+
# auto_clean_dimensions_entries_for_all_projects only cleans the DB, not the bucket.
995+
s3_file_manager.soft_delete_objects(objects=[plain_vcf_name], bucket_name=bucket_name)

0 commit comments

Comments
 (0)