Skip to content

Commit 30a6bd2

Browse files
committed
Fix KeyError for contigs missing from assembly in write_viral_gff
Contigs present in annotation TSVs but absent from the assembly FASTA caused a KeyError in three places in write_gff() This happens when the user provide proteins, as the proteins could be for contigs that were filtered out by the length filter VIRIfy use. I've added a bit of code to make it fail if all the contigs are discarded as this would a problem. Adds a regression test with some file fixtures.
1 parent 7584428 commit 30a6bd2

8 files changed

Lines changed: 129 additions & 25 deletions

File tree

bin/write_viral_gff.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,7 @@ def aggregate_annotations(
148148
# and prophage predictions can extend beyond the original contig boundaries
149149
clean_contig_name = Record.remove_prophage_from_contig(contig)
150150
contig_len = contigs_len_dict[clean_contig_name]
151-
does_the_prophage_overrun = (
152-
prophage_end > contig_len
153-
)
151+
does_the_prophage_overrun = prophage_end > contig_len
154152

155153
if does_the_prophage_overrun:
156154
# We truncate as the prophage_end could overrun
@@ -177,7 +175,7 @@ def aggregate_annotations(
177175
if best_hit != "No hit":
178176
best_hit = best_hit.replace(".faa", "")
179177
viphog_annotation = ";".join(
180-
[f"viphog={best_hit}", f'viphog_taxonomy={row["Label"]}']
178+
[f"viphog={best_hit}", f"viphog_taxonomy={row['Label']}"]
181179
)
182180
# We need to remove all the virify prophage annotations, if any
183181
contig_name_clean = Record.remove_prophage_from_contig(contig)
@@ -212,6 +210,7 @@ def write_gff(
212210
virify_quality,
213211
contigs_len_dict,
214212
ena_mapping=None,
213+
user_proteins=False,
215214
):
216215
"""Generate a GFF3 file from VIRify output files with comprehensive viral sequence annotations.
217216
@@ -229,7 +228,8 @@ def write_gff(
229228
:param ena_mapping: Optional ENA contig mapping for renaming (ERZ accession will be used if provided)
230229
:param contigs_len_dict: Optional pre-loaded dictionary mapping contig names to lengths.
231230
If not provided, will be loaded from assembly_file.
232-
231+
:param ena_mapping: ENA mapping dict
232+
:param user_proteins: Flag used when users provide their "proteins"
233233
:return: None (writes GFF file to disk)
234234
"""
235235
if ena_mapping:
@@ -307,13 +307,33 @@ def empty_if_number(string):
307307
# Collect all sequence-region headers
308308
sequence_regions = []
309309
used_contigs = set()
310+
311+
missed_contigs = 0
312+
310313
for contig_name in viral_sequences.keys():
311314
clean_contig_name = Record.remove_prophage_from_contig(contig_name)
312315
if clean_contig_name not in used_contigs:
313316
used_contigs.add(clean_contig_name)
314-
contig_length = contigs_len_dict[clean_contig_name]
317+
# Users may provide proteins for all the contigs, but VIRify only considers contigs
318+
# that are longer than 150K, so when users provide a proteins file (--user_proteins)
319+
# we allow mismatches here. Guard applies regardless of user_proteins to avoid
320+
# writing None as contig length in the GFF3 sequence-region directive.
321+
contig_length = contigs_len_dict.get(clean_contig_name)
322+
if contig_length is None:
323+
missed_contigs += 1
324+
continue
315325
sequence_regions.append((clean_contig_name, contig_length))
316326

327+
if missed_contigs > 0:
328+
logging.warning(
329+
f"{missed_contigs} contigs were not found in the assembly and were skipped"
330+
)
331+
332+
if not sequence_regions:
333+
raise ValueError(
334+
"All the contigs that came from the annotated viral sequences were discarded."
335+
)
336+
317337
# Sort sequence-region headers by contig name
318338
sequence_regions.sort(key=lambda x: x[0])
319339

@@ -331,7 +351,9 @@ def empty_if_number(string):
331351
element_category = "viral_sequence"
332352
id_ = f"ID={clean_contig_name}|viral_sequence"
333353
start = 1
334-
end = contigs_len_dict[clean_contig_name]
354+
end = contigs_len_dict.get(clean_contig_name)
355+
if end is None:
356+
continue
335357
mobile_element_type = viral_seq_type
336358

337359
if "prophage" in viral_seq_type:
@@ -385,9 +407,12 @@ def empty_if_number(string):
385407
region_name = "_".join(cds_id.split("_")[:-1])
386408
cds_id = cds_id.replace("prophage-0:", "prophage-1:")
387409

388-
# TODO: review this rule.
389-
if end > contigs_len_dict[contig_name]:
390-
end = contigs_len_dict[contig_name]
410+
contig_len = contigs_len_dict.get(contig_name)
411+
if contig_len is None:
412+
continue
413+
414+
if end > contig_len:
415+
end = contig_len
391416

392417
quality = (
393418
virify_quality[region_name]
@@ -570,6 +595,7 @@ def empty_if_number(string):
570595
)
571596

572597
logging.info("Generating the gff output")
598+
573599
write_gff(
574600
checkv_files,
575601
taxonomy_files,
@@ -580,4 +606,5 @@ def empty_if_number(string):
580606
virify_quality,
581607
contigs_len_dict,
582608
ena_mapping=ena_mapping,
609+
user_proteins=args.use_proteins,
583610
)

modules/local/write_gff/main.nf

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ process WRITE_GFF {
1313
script:
1414
def use_proteins_flag = params.use_proteins ? "--use-proteins": "" ;
1515
"""
16-
write_viral_gff.py \
17-
$use_proteins_flag \
18-
-v ${viphos_annotations.join(' ')} \
19-
-c ${quality_summaries.join(' ')} \
20-
-t ${taxonomies.join(' ')} \
21-
-s ${meta.id} \
22-
-a ${fasta}
16+
write_viral_gff.py \\
17+
$use_proteins_flag \\
18+
-v ${viphos_annotations.join(' ')} \\
19+
-c ${quality_summaries.join(' ')} \\
20+
-t ${taxonomies.join(' ')} \\
21+
-s ${meta.id} \\
22+
-a ${fasta}
2323
2424
gt gff3validator ${meta.id}_virify.gff
2525
"""

subworkflows/local/annotate.nf

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,6 @@ include { TABIX_BGZIP } from '../../modules/nf-core/tabix/bgzip/
1414
include { TABIX_BGZIPTABIX } from '../../modules/nf-core/tabix/bgziptabix/main'
1515

1616
/* Local modules */
17-
include { VIRSORTER } from '../../modules/local/virsorter'
18-
include { VIRFINDER } from '../../modules/local/virfinder'
19-
include { PPRMETA } from '../../modules/local/pprmeta'
20-
include { LENGTH_FILTERING } from '../../modules/local/length_filtering'
2117
include { RATIO_EVALUE } from '../../modules/local/ratio_evalue'
2218
include { ANNOTATION } from '../../modules/local/annotation'
2319
include { ASSIGN } from '../../modules/local/assign'
@@ -54,6 +50,7 @@ workflow ANNOTATE {
5450

5551
// prodigal
5652
PREDICT_PROTEINS( input_fastas )
53+
5754
contigs = PREDICT_PROTEINS.out.contigs
5855
proteins = PREDICT_PROTEINS.out.proteins
5956
predicted_contigs = PREDICT_PROTEINS.out.predicted_contigs
@@ -88,12 +85,12 @@ workflow ANNOTATE {
8885
checkv_db.first()
8986
)
9087

91-
viphos_annotations = ANNOTATION.out.annotations.map{meta, type, annotation -> [meta, annotation]}.groupTuple()
92-
taxonomy_annotations = ASSIGN.out.map{meta, type, annotation -> [meta, annotation]}.groupTuple()
93-
checkv_results = CHECKV.out.map{meta, type, quality -> [meta, quality]}.groupTuple()
88+
viphos_annotations = ANNOTATION.out.annotations.map { meta, _type, annotation -> [meta, annotation] }.groupTuple()
89+
taxonomy_annotations = ASSIGN.out.map { meta, _type, annotation -> [meta, annotation] }.groupTuple()
90+
checkv_results = CHECKV.out.map { meta, _type, quality -> [meta, quality] }.groupTuple()
9491

9592
WRITE_GFF(
96-
contigs.join(viphos_annotations).join(taxonomy_annotations).join(checkv_results)
93+
contigs.join(viphos_annotations).join(taxonomy_annotations).join(checkv_results),
9794
)
9895

9996
/**********************************************/

tests/test_write_gff.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
#!/bin/env python3
22

33
import os
4+
from pathlib import Path
45
import unittest
6+
from unittest.mock import patch
57
import glob
68
import hashlib
79

@@ -167,3 +169,56 @@ def test_prophage_coordinate_truncation_in_gff(self):
167169
# Clean up
168170
if os.path.exists("test_sample_virify.gff"):
169171
os.unlink("test_sample_virify.gff")
172+
173+
174+
def test_contig_missing_from_assembly_is_skipped_with_warning(tmp_path):
175+
"""Contig present in annotation TSV but absent from the assembly FASTA is skipped.
176+
177+
Regression test for KeyError: '<contig_name>' when a contig in the
178+
annotation file has no matching entry in contigs_len_dict.
179+
Both the sequence-region header, the mobile-element record, and any
180+
associated CDS records for the missing contig must be omitted from the
181+
output GFF, and a WARNING must be emitted reporting the count.
182+
"""
183+
184+
fixtures = Path(__file__).parent / "write_gff_missing_contig_fixtures"
185+
assembly_fasta = fixtures / "assembly.fasta"
186+
annotation_tsv = fixtures / "high_confidence_viral_contigs_annotation.tsv"
187+
checkv_tsv = fixtures / "high_confidence_viral_contigs_quality_summary.tsv"
188+
taxonomy_tsv = fixtures / "high_confidence_viral_contigs_annotation_taxonomy.tsv"
189+
190+
contigs_len_dict = get_contig_lengths_per_contig(str(assembly_fasta))
191+
192+
viral_sequences, cds_annotations, virify_quality = aggregate_annotations(
193+
[str(annotation_tsv)], contigs_len_dict
194+
)
195+
196+
# Both contigs appear in annotation output before write_gff filtering
197+
assert "valid_contig" in viral_sequences
198+
assert "missing_contig" in viral_sequences
199+
200+
# Passing a full path as sample_prefix directs the output GFF into tmp_path
201+
sample_prefix = str(tmp_path / "test_missing_contig")
202+
203+
with patch("logging.warning") as mock_warn:
204+
write_gff(
205+
[str(checkv_tsv)],
206+
[str(taxonomy_tsv)],
207+
sample_prefix,
208+
str(assembly_fasta),
209+
viral_sequences,
210+
cds_annotations,
211+
virify_quality,
212+
contigs_len_dict,
213+
)
214+
215+
warned_messages = [str(call) for call in mock_warn.call_args_list]
216+
assert any(
217+
"1 contigs were not found in the assembly and were skipped" in msg
218+
for msg in warned_messages
219+
)
220+
221+
output_gff = tmp_path / "test_missing_contig_virify.gff"
222+
gff_content = output_gff.read_text()
223+
assert "valid_contig" in gff_content
224+
assert "missing_contig" not in gff_content
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
>valid_contig
2+
GCTACCTGGTGCACCGTGCCGCCTTGCCAGCCCAATGAGTGCATGAGATGTAAAGGAGTATTTGTCATGG
3+
TATCGCCCTTTCTTTGATGTGCGCCCTGGTTGGCGCGGTAGAAAGATAATAGCCGTTTCCTTTATAGATA
4+
GCAAGCAAAAAAGAAATGAAAAAAAGATTAAAAAAAATGGTTGACGTTCCTCACCTGGTAGCGTAGTATT
5+
CATCTTGTCGAGGGGCGGCAATAGTGCCGCTCATAACAGAAGAGAGAAATAATCATGGCATCATATAGCG
6+
CATCAATGATCGTAAAGGGTATCACTGGTAAACCTATGCGCGGTGGCGGATCTTTCGCTAACATCAATAT
7+
TGATGGACGTTTATCCATGGACGCAGCGATCACAGTGGCACGGGAAACATTTAAAAAAGAATGTAATTTC
8+
AATAAACAAGATTATCTCGGTTTCGCTATTGAAAAAACCGCGCGCTTTGTGGATTATAAAAGCCCGAAAA
9+
TGATCGACACTACACTAAAAGCGAAAGATGTAGCGTTTTTGCTTTAAGGCTTGACACTGAAAAATATTTT
10+
GGTAATATATACCACATAAGGCGGGGAAATGGTTTCCCGCCAAATCAAAGAAAGGGTAATGCAATGAGCA
11+
AGCAAATCAATACTATCATGTTTAATCTGGTATCTTTCCGCGATGACGTGAAAAACCTTCCGCGTGATGC
12+
AGTGGATGCACGGATCGAAAGCTATCGCACACAGATCGCAACGTTACCGCTGAAACGTGATCAACATGCC
13+
GCTAATATGATGCTTGATGCTATGATTAAAAAGATGATTCAATGTGATCTTACCCTGGCTTATGAATATG
14+
CTGGCGATCTCTTTGTAACATATAGCAAGCCCGTTCCAGGTATGAGCACCACGGAAGAATTGCATGCCTT
15+
AAATCTGCGACAAGAAAATCTGCGACAAGAAAATCTGCGACAAGAAAATCTGCGACAAGAAAATCTGCGA
16+
CAAGAAAATCTGCGACAAGA
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Contig CDS_ID Start End Direction Best_hit Abs_Evalue_exp Label
2+
valid_contig valid_contig_1 1 100 1 No hit NA
3+
missing_contig missing_contig_1 1 100 1 No hit NA
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
contig_ID superkingdom kingdom phylum subphylum class order suborder family subfamily genus
2+
valid_contig
3+
missing_contig
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
contig_id contig_length provirus proviral_length gene_count viral_genes host_genes checkv_quality miuvig_quality completeness completeness_method contamination kmer_freq warnings
2+
valid_contig 1000 No NA 2 1 0 Medium-quality Genome-fragment 80.0 HMM-based 0.0 1.0
3+
missing_contig 1000 No NA 2 1 0 Medium-quality Genome-fragment 80.0 HMM-based 0.0 1.0

0 commit comments

Comments
 (0)