Skip to content

Commit bab3cfc

Browse files
authored
Merge pull request #7 from Robaina/add-test-suite
Add test suite and CI
2 parents d698e76 + 7343a55 commit bab3cfc

7 files changed

Lines changed: 329 additions & 0 deletions

File tree

.github/workflows/tests.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- uses: actions/setup-python@v5
15+
with:
16+
python-version: "3.10"
17+
18+
- name: Install samtools
19+
run: |
20+
sudo apt-get update
21+
sudo apt-get install -y samtools
22+
23+
- name: Install dependencies
24+
run: |
25+
python -m pip install --upgrade pip
26+
python -m pip install pysam numpy pytest parallelbam
27+
python -m pip install -e . --no-deps
28+
29+
- name: Run tests
30+
run: python -m pytest

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ lib64
1414

1515
__pycache__
1616
.ipynb_checkpoints
17+
.pytest_cache

pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[pytest]
2+
testpaths = tests
3+
addopts = -ra

tests/conftest.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
Shared fixtures for the filtersam test suite.
3+
4+
A small, hand-crafted SAM file with segments whose percent-identity and
5+
percent-matched values are known exactly is used throughout. The values were
6+
verified against pysam's own ``get_aligned_pairs``/CIGAR parsing:
7+
8+
name %identity %matched MD tag
9+
read_perfect 100.0 100.0 MD:Z:10
10+
read_90id 90.0 90.0 MD:Z:5A4
11+
read_softclip 100.0 80.0 MD:Z:8 (CIGAR 2S8M)
12+
read_70id 70.0 70.0 MD:Z:1A2C2A2
13+
read_nomd ---- ---- (no MD tag, always dropped)
14+
"""
15+
16+
import pysam
17+
import pytest
18+
19+
SAM_TEXT = """@HD\tVN:1.6\tSO:unsorted
20+
@SQ\tSN:ref\tLN:100
21+
read_perfect\t0\tref\t1\t60\t10M\t*\t0\t0\tACGTACGTAC\t*\tMD:Z:10
22+
read_90id\t0\tref\t1\t60\t10M\t*\t0\t0\tACGTAGGTAC\t*\tMD:Z:5A4
23+
read_softclip\t0\tref\t1\t60\t2S8M\t*\t0\t0\tTTACGTACGT\t*\tMD:Z:8
24+
read_70id\t0\tref\t1\t60\t10M\t*\t0\t0\tAGGTGGTGAC\t*\tMD:Z:1A2C2A2
25+
read_nomd\t0\tref\t1\t60\t10M\t*\t0\t0\tACGTACGTAC\t*\tNM:i:0
26+
"""
27+
28+
# Expected segments retained for a given filter and cutoff.
29+
IDENTITY_KEPT = {
30+
95.0: {"read_perfect", "read_softclip"},
31+
90.0: {"read_perfect", "read_90id", "read_softclip"},
32+
70.0: {"read_perfect", "read_90id", "read_softclip", "read_70id"},
33+
}
34+
MATCHED_KEPT = {
35+
100.0: {"read_perfect"},
36+
85.0: {"read_perfect", "read_90id"},
37+
50.0: {"read_perfect", "read_90id", "read_softclip", "read_70id"},
38+
}
39+
40+
41+
def _write_sam(path):
42+
path.write_text(SAM_TEXT)
43+
return path
44+
45+
46+
def read_segment_names(path):
47+
"""Return the set of query names in a SAM/BAM file (format auto-detected)."""
48+
save = pysam.set_verbosity(0)
49+
with pysam.AlignmentFile(str(path), "r") as handle:
50+
names = {seg.query_name for seg in handle}
51+
pysam.set_verbosity(save)
52+
return names
53+
54+
55+
def detect_format(path):
56+
"""Return 'bam' if the file is BGZF/BAM-compressed, 'sam' if plain text."""
57+
with open(path, "rb") as handle:
58+
magic = handle.read(2)
59+
return "bam" if magic == b"\x1f\x8b" else "sam"
60+
61+
62+
@pytest.fixture
63+
def sam_path(tmp_path):
64+
"""A plain-text SAM fixture file."""
65+
return _write_sam(tmp_path / "sample.sam")
66+
67+
68+
@pytest.fixture
69+
def bam_path(tmp_path):
70+
"""A BAM fixture file built from the same records as ``sam_path``."""
71+
sam = _write_sam(tmp_path / "_src.sam")
72+
bam = tmp_path / "sample.bam"
73+
save = pysam.set_verbosity(0)
74+
with pysam.AlignmentFile(str(sam), "r") as src:
75+
with pysam.AlignmentFile(str(bam), "wb", template=src) as dst:
76+
for seg in src:
77+
dst.write(seg)
78+
pysam.set_verbosity(save)
79+
return bam
80+
81+
82+
@pytest.fixture
83+
def segments(sam_path):
84+
"""The parsed AlignedSegment objects, keyed by query name."""
85+
save = pysam.set_verbosity(0)
86+
with pysam.AlignmentFile(str(sam_path), "r") as handle:
87+
segs = {seg.query_name: seg for seg in handle}
88+
pysam.set_verbosity(save)
89+
return segs

tests/test_filter.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Tests for the single-file filtering functions:
3+
filterSAMbyIdentity and filterSAMbyPercentMatched.
4+
"""
5+
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
from filtersam.filtersam import filterSAMbyIdentity, filterSAMbyPercentMatched
11+
12+
from conftest import IDENTITY_KEPT, MATCHED_KEPT, detect_format, read_segment_names
13+
14+
15+
@pytest.mark.parametrize("cutoff", sorted(IDENTITY_KEPT))
16+
def test_filter_by_identity_keeps_expected(sam_path, tmp_path, cutoff):
17+
out = tmp_path / "out.sam"
18+
filterSAMbyIdentity(sam_path, out, identity_cutoff=cutoff)
19+
assert read_segment_names(out) == IDENTITY_KEPT[cutoff]
20+
21+
22+
@pytest.mark.parametrize("cutoff", sorted(MATCHED_KEPT))
23+
def test_filter_by_matched_keeps_expected(sam_path, tmp_path, cutoff):
24+
out = tmp_path / "out.sam"
25+
filterSAMbyPercentMatched(sam_path, out, matched_cutoff=cutoff)
26+
assert read_segment_names(out) == MATCHED_KEPT[cutoff]
27+
28+
29+
def test_segments_without_md_tag_are_always_dropped(sam_path, tmp_path):
30+
# Cutoff 0 keeps everything that has an MD tag, but never the MD-less read.
31+
out = tmp_path / "out.sam"
32+
filterSAMbyIdentity(sam_path, out, identity_cutoff=0.0)
33+
assert "read_nomd" not in read_segment_names(out)
34+
35+
36+
def test_works_on_bam_input(bam_path, tmp_path):
37+
out = tmp_path / "out.bam"
38+
filterSAMbyIdentity(bam_path, out, identity_cutoff=95.0)
39+
assert read_segment_names(out) == IDENTITY_KEPT[95.0]
40+
41+
42+
# --- Output format selection (regression guard for the single-process BAM fix) ---
43+
44+
def test_output_sam_is_text(sam_path, tmp_path):
45+
out = tmp_path / "out.sam"
46+
filterSAMbyIdentity(sam_path, out, identity_cutoff=95.0)
47+
assert detect_format(out) == "sam"
48+
49+
50+
def test_output_bam_is_binary(sam_path, tmp_path):
51+
out = tmp_path / "out.bam"
52+
filterSAMbyIdentity(sam_path, out, identity_cutoff=95.0)
53+
assert detect_format(out) == "bam"
54+
55+
56+
def test_output_format_follows_output_extension_not_input(bam_path, tmp_path):
57+
# BAM input but a .sam output request must yield text SAM.
58+
out = tmp_path / "out.sam"
59+
filterSAMbyIdentity(bam_path, out, identity_cutoff=95.0)
60+
assert detect_format(out) == "sam"
61+
assert read_segment_names(out) == IDENTITY_KEPT[95.0]
62+
63+
64+
# --- Default output path naming (regression guard for the suffix fix) ---
65+
66+
def test_default_output_path_naming(tmp_path):
67+
# A filename containing 'sam' before the extension used to break the old
68+
# regex-based extension detection; the suffix-based logic handles it.
69+
src = tmp_path / "mysample.bam"
70+
# Build a tiny BAM from the SAM fixture text.
71+
from conftest import SAM_TEXT
72+
import pysam
73+
sam = tmp_path / "_seed.sam"
74+
sam.write_text(SAM_TEXT)
75+
save = pysam.set_verbosity(0)
76+
with pysam.AlignmentFile(str(sam), "r") as s, \
77+
pysam.AlignmentFile(str(src), "wb", template=s) as d:
78+
for seg in s:
79+
d.write(seg)
80+
pysam.set_verbosity(save)
81+
82+
filterSAMbyIdentity(src, identity_cutoff=95.0)
83+
84+
expected = tmp_path / "mysample.identity_filtered_at_95.0.bam"
85+
assert expected.is_file()
86+
assert detect_format(expected) == "bam"
87+
assert read_segment_names(expected) == IDENTITY_KEPT[95.0]

tests/test_filterSAM.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""
2+
Tests for the filterSAM dispatcher: argument validation and the
3+
single-vs-parallel routing logic.
4+
"""
5+
6+
import pytest
7+
8+
from filtersam import filtersam as fs
9+
from filtersam.filtersam import filterSAM
10+
11+
from conftest import IDENTITY_KEPT, read_segment_names
12+
13+
14+
def test_invalid_filter_by_raises(sam_path, tmp_path):
15+
with pytest.raises(ValueError):
16+
filterSAM(sam_path, tmp_path / "out.sam", filter_by="nonsense", cutoff=95.0)
17+
18+
19+
@pytest.mark.parametrize("cutoff", [-1.0, 100.1, 1000.0])
20+
def test_out_of_range_cutoff_raises(sam_path, tmp_path, cutoff):
21+
with pytest.raises(ValueError):
22+
filterSAM(sam_path, tmp_path / "out.sam", filter_by="identity", cutoff=cutoff)
23+
24+
25+
def test_none_processes_uses_single_path(bam_path, tmp_path, monkeypatch):
26+
calls = []
27+
monkeypatch.setattr(fs, "parallelizeBAMoperation",
28+
lambda *a, **k: calls.append((a, k)))
29+
out = tmp_path / "out.bam"
30+
filterSAM(bam_path, out, filter_by="identity", cutoff=95.0, n_processes=None)
31+
assert calls == []
32+
assert read_segment_names(out) == IDENTITY_KEPT[95.0]
33+
34+
35+
def test_single_process_does_not_split(bam_path, tmp_path, monkeypatch):
36+
# Regression guard for #3 / PR #6: `-p 1` must take the direct path and
37+
# never invoke the (expensive) parallel splitting machinery.
38+
calls = []
39+
monkeypatch.setattr(fs, "parallelizeBAMoperation",
40+
lambda *a, **k: calls.append((a, k)))
41+
out = tmp_path / "out.bam"
42+
filterSAM(bam_path, out, filter_by="identity", cutoff=95.0, n_processes=1)
43+
assert calls == [], "n_processes=1 should not call parallelizeBAMoperation"
44+
assert read_segment_names(out) == IDENTITY_KEPT[95.0]
45+
46+
47+
def test_multiple_processes_use_parallel_path(bam_path, tmp_path, monkeypatch):
48+
calls = []
49+
monkeypatch.setattr(fs, "parallelizeBAMoperation",
50+
lambda *a, **k: calls.append((a, k)))
51+
out = tmp_path / "out.bam"
52+
filterSAM(bam_path, out, filter_by="identity", cutoff=95.0, n_processes=2)
53+
assert len(calls) == 1, "n_processes>1 should call parallelizeBAMoperation"
54+
55+
56+
def test_parallel_run_matches_serial(bam_path, tmp_path):
57+
# End-to-end parallel run (uses samtools-backed splitting from parallelbam).
58+
out = tmp_path / "out_parallel.bam"
59+
filterSAM(bam_path, out, filter_by="identity", cutoff=70.0, n_processes=2)
60+
assert read_segment_names(out) == IDENTITY_KEPT[70.0]

tests/test_metrics.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""
2+
Unit tests for the per-segment metric helpers in filtersam.filtersam.
3+
"""
4+
5+
import pytest
6+
7+
from filtersam import filtersam as fs
8+
9+
10+
def test_has_md_tag(segments):
11+
assert fs.has_MD_tag(segments["read_perfect"])
12+
assert not fs.has_MD_tag(segments["read_nomd"])
13+
14+
15+
def test_sum_matches_and_mismatches(segments):
16+
# Sum of CIGAR M lengths.
17+
assert fs.sumMatchesAndMismatches(segments["read_perfect"]) == 10
18+
assert fs.sumMatchesAndMismatches(segments["read_90id"]) == 10
19+
# Soft-clipped bases (2S) do not count towards M.
20+
assert fs.sumMatchesAndMismatches(segments["read_softclip"]) == 8
21+
22+
23+
def test_get_number_of_matches(segments):
24+
assert fs.getNumberOfMatches(segments["read_perfect"]) == 10
25+
assert fs.getNumberOfMatches(segments["read_90id"]) == 9
26+
assert fs.getNumberOfMatches(segments["read_softclip"]) == 8
27+
assert fs.getNumberOfMatches(segments["read_70id"]) == 7
28+
29+
30+
def test_get_query_length(segments):
31+
# M + I + S + = + X consume query; soft clip is included.
32+
assert fs.getQueryLength(segments["read_perfect"]) == 10
33+
assert fs.getQueryLength(segments["read_softclip"]) == 10
34+
35+
36+
@pytest.mark.parametrize(
37+
"name,expected",
38+
[
39+
("read_perfect", 100.0),
40+
("read_90id", 90.0),
41+
("read_softclip", 100.0),
42+
("read_70id", 70.0),
43+
],
44+
)
45+
def test_percent_identity(segments, name, expected):
46+
assert fs.percent_identity(segments[name]) == pytest.approx(expected)
47+
48+
49+
@pytest.mark.parametrize(
50+
"name,expected",
51+
[
52+
("read_perfect", 100.0),
53+
("read_90id", 90.0),
54+
("read_softclip", 80.0),
55+
("read_70id", 70.0),
56+
],
57+
)
58+
def test_percent_matched(segments, name, expected):
59+
assert fs.percent_matched(segments[name]) == pytest.approx(expected)

0 commit comments

Comments
 (0)