Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 95 additions & 1 deletion tests/test_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from virtool.workflow.pytest_plugin import WorkflowData

from workflow import (
build_isolate_index,
create_reference_index,
create_subtraction_index,
eliminate_subtraction,
Expand Down Expand Up @@ -626,7 +627,10 @@ async def test_map_isolates(

proc = 1

intermediate = SimpleNamespace(lengths={"foo": 100})

await map_isolates(
intermediate,
isolate_fastq_path,
isolate_index_path,
isolate_bam_path,
Expand Down Expand Up @@ -676,7 +680,7 @@ async def test_eliminate_subtraction(
if no_subtractions:
subtractions = []

intermediate = SimpleNamespace()
intermediate = SimpleNamespace(lengths={"foo": 100})

if subtractions:
cached_subtraction_path = tmp_path / str(subtractions[0].id)
Expand Down Expand Up @@ -829,3 +833,93 @@ async def test_pathoscope(
report[split[0]] = [float(f"{float(n):.5g}") for n in split[1:]]

assert report == snapshot


async def test_build_isolate_index_no_candidates(
index: WFIndex,
work_path: Path,
):
"""When no candidate OTUs are found the isolate FASTA is empty.

``bowtie2-build`` exits 1 on an empty FASTA, so the index build must be skipped
entirely (VIR-2569) rather than invoking the subprocess.
"""
write_reference_json(index.json_path)

isolate_path = work_path / "isolates"
isolate_path.mkdir()

run_subprocess = FakeRunSubprocess()
intermediate = SimpleNamespace(to_otus=set())

await build_isolate_index(
index,
intermediate,
isolate_path / "isolate_index.fa",
isolate_path / "isolates",
run_subprocess,
2,
)

assert intermediate.lengths == {}
assert run_subprocess.commands == []


async def test_no_candidates_uploads_empty_result(
analysis: WFAnalysis,
index: WFIndex,
sample: WFSample,
work_path: Path,
):
"""With no candidate OTUs the downstream steps short-circuit.

``map_isolates``, ``eliminate_subtraction`` and ``reassignment`` must not run any
subprocess and the analysis is finished with an empty result (VIR-2569).
"""
run_subprocess = FakeRunSubprocess()
intermediate = SimpleNamespace(lengths={})
logger = get_logger("test")
results = {}

await map_isolates(
intermediate,
work_path / "isolate_mapped.fq",
work_path / "isolates",
work_path / "to_isolates.bam",
2,
run_subprocess,
sample,
)

await eliminate_subtraction(
intermediate,
work_path / "isolate_mapped.fq",
work_path / "to_isolates.bam",
logger,
0.01,
2,
results,
run_subprocess,
work_path / "subtraction_indexes",
[],
work_path / "subtracted.bam",
work_path,
)

assert results["subtracted_count"] == 0

await reassignment(
analysis,
index,
intermediate,
logger,
0.01,
results,
work_path / "subtracted.bam",
work_path,
)

assert run_subprocess.commands == []
analysis.upload_result.assert_called_once_with(
{"subtracted_count": 0, "read_count": 0, "hits": []},
)
30 changes: 30 additions & 0 deletions workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@ async def build_isolate_index(
isolate_fasta_path,
)

if not intermediate.lengths:
# A sample can have zero reads mapping to any candidate OTU, producing an
# empty isolate FASTA. bowtie2-build exits 1 on empty input (VIR-2569), so
# skip building the index. The remaining mapping and reassignment steps
# short-circuit on the empty ``intermediate.lengths`` and the analysis is
# finished with an empty result.
return

await build_bowtie2_index(
isolate_fasta_path,
isolate_index_path,
Expand All @@ -175,6 +183,7 @@ async def build_isolate_index(

@step
async def map_isolates(
intermediate: SimpleNamespace,
isolate_fastq_path: Path,
isolate_index_path: Path,
isolate_bam_path: Path,
Expand All @@ -183,6 +192,11 @@ async def map_isolates(
sample: WFSample,
):
"""Map sample reads to the all isolate index."""
if not intermediate.lengths:
# No candidate OTUs were found, so no isolate index was built. There is
# nothing to map against.
return

read_paths = ",".join(str(path) for path in sample.read_paths)

bowtie_cmd = (
Expand Down Expand Up @@ -238,6 +252,12 @@ async def eliminate_subtraction(
:param work_path: path to the workflow working directory
"""

if not intermediate.lengths:
# No candidate OTUs were found, so no reads were mapped to isolates and
# there is nothing to subtract.
results["subtracted_count"] = 0
return

if len(subtractions) == 0:
logger.info("no subtractions to eliminate reads against")
# Rename BAM file as no subtraction is needed (saves disk space)
Expand Down Expand Up @@ -333,6 +353,16 @@ async def reassignment(
Tab-separated output is written to ``pathoscope.tsv``. The results are also parsed
and saved to `intermediate.coverage`.
"""
if not intermediate.lengths:
# No candidate OTUs were found, so no isolate index was built and no reads
# were mapped. Finish the analysis with an empty result instead of running
# Pathoscope on a non-existent alignment.
logger.info("no candidate otus found; uploading empty result")

await analysis.upload_result({**results, "read_count": 0, "hits": []})

return

logger.info(
"running pathoscope",
subtracted_path=subtracted_bam_path,
Expand Down