import json
import tempfile
from pathlib import Path
import pytest
import ferro_hgvs
SEQUENCE = "GGATTACAGGCAAAAGCCTGAGGATTACAGGCATTAGCCT"
@pytest.fixture(scope="module")
def normalizer():
reference = {
"transcripts": [
{
"id": "TEMPLATE",
"gene_symbol": "T",
"strand": "+",
"sequence": SEQUENCE,
"exons": [{"number": 1, "start": 1, "end": len(SEQUENCE)}],
}
]
}
path = Path(tempfile.mkdtemp()) / "reference.json"
path.write_text(json.dumps(reference))
return ferro_hgvs.Normalizer(reference_json=str(path))
def test_derives_a_description_from_bases_alone():
variant = ferro_hgvs.from_sequences("NC_000001.11", 1000, "AGCG", "AG")
assert str(variant) == "NC_000001.11:g.1002_1003del"
def test_the_position_is_one_based():
assert str(ferro_hgvs.from_sequences("NC_1", 1, "AGCG", "AG")) == "NC_1:g.3_4del"
assert str(ferro_hgvs.from_sequences("NC_1", 2, "AGCG", "AG")) == "NC_1:g.4_5del"
def test_the_same_bases_give_the_same_description():
calls = [ferro_hgvs.from_sequences("NC_1", 5, "GGCATTAGC", "GGCTTAGC") for _ in range(8)]
assert len({str(v) for v in calls}) == 1
def test_an_unchanged_pair_is_the_identity_description():
assert str(ferro_hgvs.from_sequences("NC_1", 5, "GGCA", "GGCA")) == "NC_1:g.="
def test_the_options_are_keyword_only():
with pytest.raises(TypeError):
ferro_hgvs.from_sequences("NC_1", 1, "AGCG", "AG", 1024)
def test_the_partitioner_places_a_pure_indel_three_prime():
three = ferro_hgvs.from_sequences("NC_1", 10, "CAAAAG", "CAAAG")
assert str(three) == "NC_1:g.14del"
def test_a_direction_keyword_is_a_type_error():
for value in ("3prime", "5prime", "sideways"):
with pytest.raises(TypeError, match="unexpected keyword argument 'direction'"):
ferro_hgvs.from_sequences("NC_1", 1, "AGCG", "AG", direction=value)
def test_a_zero_grid_budget_is_a_value_error():
with pytest.raises(ValueError, match="max_grid_cells must be positive"):
ferro_hgvs.from_sequences("NC_1", 1, "AGCG", "AG", max_grid_cells=0)
def test_a_grid_over_budget_refuses_and_names_the_knob():
with pytest.raises(ferro_hgvs.NormalizationError) as excinfo:
ferro_hgvs.from_sequences("NC_1", 1, "ACGTACGTAC", "TGCATGCATG", max_grid_cells=4)
assert "max_grid_cells" in str(excinfo.value)
def test_a_transcript_accession_is_refused():
with pytest.raises(ferro_hgvs.NormalizationError):
ferro_hgvs.from_sequences("NM_000088.3", 10, "AGCG", "AG")
@pytest.mark.parametrize(
("position", "reference", "alternate"),
[
(0, "AGCG", "AG"), (1, "", "AG"), (1, "AGXG", "AG"), ],
)
def test_unusable_input_refuses(position, reference, alternate):
with pytest.raises(ferro_hgvs.NormalizationError):
ferro_hgvs.from_sequences("NC_1", position, reference, alternate)
def test_the_two_refusal_classes_are_the_established_split():
binding_checks = (lambda: ferro_hgvs.from_sequences("NC_1", 1, "AGCG", "AG", max_grid_cells=0),)
for call in binding_checks:
with pytest.raises(ValueError) as excinfo:
call()
assert not isinstance(excinfo.value, ferro_hgvs.FerroError)
with pytest.raises(ferro_hgvs.FerroError):
ferro_hgvs.from_sequences("NC_1", 0, "AGCG", "AG")
def test_a_derivation_reports_whether_it_reached_a_window_edge():
interior = ferro_hgvs.from_sequences_detailed("NC_1", 10, "CAAAAGCC", "CAAAGCC")
assert interior.placement_bounded_by_window is False
flush = ferro_hgvs.from_sequences_detailed("NC_1", 10, "AAAA", "AAA")
assert flush.placement_bounded_by_window is True
assert str(flush.variant) == "NC_1:g.13del"
def test_the_detailed_variant_matches_the_plain_call():
plain = ferro_hgvs.from_sequences("NC_1", 10, "CAAAAGCC", "CAAAGCC")
detailed = ferro_hgvs.from_sequences_detailed("NC_1", 10, "CAAAAGCC", "CAAAGCC")
assert str(detailed.variant) == str(plain)
def test_derived_description_has_a_useful_repr():
rendered = repr(ferro_hgvs.from_sequences_detailed("NC_1", 10, "CAAAAGCC", "CAAAGCC"))
assert rendered.startswith("DerivedDescription(")
assert "placement_bounded_by_window" in rendered
def test_to_sequences_returns_a_one_based_window(normalizer):
variant = normalizer.parse("TEMPLATE:n.13del")
pair = normalizer.to_sequences(variant, pad=4)
assert pair.accession == "TEMPLATE"
assert SEQUENCE[pair.position - 1 : pair.position - 1 + len(pair.reference)] == pair.reference
assert len(pair.alternate) == len(pair.reference) - 1
assert pair.window_is_final is True
def test_to_sequences_pads_both_sides(normalizer):
variant = normalizer.parse("TEMPLATE:n.13del")
pair = normalizer.to_sequences(variant, pad=4)
assert pair.position < 13
assert pair.position - 1 + len(pair.reference) > 13
def test_to_sequences_clamps_the_pad_to_the_sequence(normalizer):
variant = normalizer.parse("TEMPLATE:n.2del")
pair = normalizer.to_sequences(variant, pad=100)
assert pair.position == 1
assert pair.reference == SEQUENCE
assert pair.window_is_final is True
def test_to_sequences_refuses_what_apply_to_reference_refuses(normalizer):
with pytest.raises(ferro_hgvs.ProjectionError):
normalizer.to_sequences(normalizer.parse("NOT_A_TEMPLATE:n.13del"), pad=4)
def test_sequence_pair_has_a_useful_repr(normalizer):
rendered = repr(normalizer.to_sequences(normalizer.parse("TEMPLATE:n.13del"), pad=4))
assert rendered.startswith("SequencePair(")
assert "position=" in rendered
def test_sequence_pairs_compare_by_value(normalizer):
variant = normalizer.parse("TEMPLATE:n.13del")
one = normalizer.to_sequences(variant, pad=4)
same = normalizer.to_sequences(variant, pad=4)
wider = normalizer.to_sequences(variant, pad=6)
assert one == same
assert one is not same
assert one != wider
assert len({one, same, wider}) == 2
def test_a_description_survives_the_round_trip(normalizer):
for spelling in ["TEMPLATE:n.13del", "TEMPLATE:n.3_5del", "TEMPLATE:n.13dup"]:
pair = normalizer.to_sequences(normalizer.parse(spelling), pad=16)
derived = normalizer.from_sequences(
pair.accession, pair.position, pair.reference, pair.alternate
)
assert normalizer.canonical_spdi(derived) == normalizer.canonical_spdi(
normalizer.parse(spelling)
)
def test_the_method_refuses_an_interval_past_the_sequence_end(normalizer):
with pytest.raises(ferro_hgvs.NormalizationError):
normalizer.from_sequences("TEMPLATE", len(SEQUENCE), "AGCG", "AG")
def test_the_method_refuses_an_unknown_accession(normalizer):
with pytest.raises(ferro_hgvs.NormalizationError):
normalizer.from_sequences("NOT_A_TEMPLATE", 10, "CAAAAG", "CAAAG")
def test_the_method_takes_no_direction_of_its_own():
reference = {
"transcripts": [
{
"id": "TEMPLATE",
"gene_symbol": "T",
"strand": "+",
"sequence": SEQUENCE,
"exons": [{"number": 1, "start": 1, "end": len(SEQUENCE)}],
}
]
}
path = Path(tempfile.mkdtemp()) / "reference.json"
path.write_text(json.dumps(reference))
args = ("TEMPLATE", 10, "CAAAAG", "CAAAG")
default = ferro_hgvs.Normalizer(reference_json=str(path))
from_manifestless = ferro_hgvs.Normalizer(reference_json=str(path))
assert str(default.from_sequences(*args)) == str(from_manifestless.from_sequences(*args))
assert str(default.from_sequences(*args)) == "TEMPLATE:g.14del"
with pytest.raises(TypeError, match="unexpected keyword argument 'direction'"):
ferro_hgvs.Normalizer(reference_json=str(path), direction="5prime")
def test_post_normalizing_is_available_and_is_a_no_op_here(normalizer):
pair = normalizer.to_sequences(normalizer.parse("TEMPLATE:n.13del"), pad=16)
plain = normalizer.from_sequences(pair.accession, pair.position, pair.reference, pair.alternate)
normalized = normalizer.from_sequences(
pair.accession, pair.position, pair.reference, pair.alternate, recommended_form=True
)
assert str(normalized) == str(plain)
def test_recommended_form_true_reaches_the_normalizer(normalizer):
plain = normalizer.from_sequences("TEMPLATE", 3, "A", "AAAA")
normalized = normalizer.from_sequences("TEMPLATE", 3, "A", "AAAA", recommended_form=True)
assert str(plain) == "TEMPLATE:g.3_4insAAA"
assert str(normalized) == "TEMPLATE:g.3A[4]"
def test_the_method_rejects_a_zero_grid_budget(normalizer):
with pytest.raises(ValueError, match="max_grid_cells must be positive"):
normalizer.from_sequences("TEMPLATE", 10, "CAAAAG", "CAAAG", max_grid_cells=0)
def test_the_surface_is_exported():
for name in (
"from_sequences",
"from_sequences_detailed",
"SequencePair",
"DerivedDescription",
):
assert hasattr(ferro_hgvs, name), f"{name} is not exported"
assert name in ferro_hgvs.__all__, f"{name} is missing from __all__"
def test_a_pair_can_be_built_from_bases_alone():
pair = ferro_hgvs.SequencePair("T", 10, "GCAAAAG", "GCAAAG")
assert (pair.position, pair.end) == (10, 16)
assert pair.window_is_final is False
def test_a_pair_that_constructs_is_a_pair_that_derives():
for position, reference, alternate in [(0, "AGCG", "AG"), (1, "", "AG"), (1, "AGXG", "AG")]:
with pytest.raises(ferro_hgvs.NormalizationError):
ferro_hgvs.SequencePair("T", position, reference, alternate)
def test_the_constructor_admits_the_iupac_ambiguity_codes():
assert ferro_hgvs.SequencePair("T", 1, "AGYG", "AG").reference == "AGYG"
for bad in ("AGUG", "AGXG", "AG-G"):
with pytest.raises(ferro_hgvs.NormalizationError):
ferro_hgvs.SequencePair("T", 1, bad, "AG")
def test_a_bound_stops_the_roll_at_the_bound():
pair = ferro_hgvs.SequencePair("NC_1", 10, "GCAAAAG", "GCAAAG")
assert str(
ferro_hgvs.from_sequences("NC_1", pair.position, pair.reference, pair.alternate)
) == ("NC_1:g.15del")
bounded = pair.trim_to(end=14)
assert (bounded.position, bounded.end) == (10, 14)
derived = ferro_hgvs.from_sequences_detailed(
"NC_1", bounded.position, bounded.reference, bounded.alternate
)
assert str(derived.variant) == "NC_1:g.14del"
assert derived.placement_bounded_by_window is True
def test_trim_to_refuses_rather_than_clamping():
pair = ferro_hgvs.SequencePair("NC_1", 10, "GCAAAAG", "GCAAAAT")
with pytest.raises(ferro_hgvs.NormalizationError, match="differ"):
pair.trim_to(end=15)
with pytest.raises(ferro_hgvs.NormalizationError, match="reanchor"):
pair.trim_to(start=5)
def test_trim_to_treats_case_as_agreement():
masked = ferro_hgvs.SequencePair("NC_1", 10, "gcaaaag", "GCAAAG")
bounded = masked.trim_to(end=14)
assert (bounded.position, bounded.end) == (10, 14)
assert bounded.reference == "gcaaa"
assert str(bounded.derive().variant) == "NC_1:g.14del"
with pytest.raises(ferro_hgvs.NormalizationError, match="differ"):
ferro_hgvs.SequencePair("NC_1", 10, "gcaaaag", "GCAAAAT").trim_to(end=15)
def test_reanchor_pads_and_trims(normalizer):
pair = normalizer.to_sequences(normalizer.parse("TEMPLATE:n.13del"), pad=2)
widened = normalizer.reanchor(pair, start=1, end=20)
assert (widened.position, widened.end) == (1, 20)
assert widened.reference == SEQUENCE[:20]
assert len(widened.alternate) == len(widened.reference) - 1
def test_reanchor_keeps_window_is_final_when_the_three_prime_edge_never_moves(normalizer):
settled = normalizer.to_sequences(normalizer.parse("TEMPLATE:n.13del"), pad=4)
assert settled.window_is_final is True
assert settled.end < len(SEQUENCE), "the fixture must stop short of the sequence end"
assert normalizer.reanchor(settled).window_is_final is True
assert normalizer.reanchor(settled, start=1).window_is_final is True
assert normalizer.reanchor(settled, end=settled.end + 1).window_is_final is False
assert normalizer.reanchor(settled, end=len(SEQUENCE)).window_is_final is True
raw = ferro_hgvs.SequencePair("TEMPLATE", 10, "GCAAAAG", "GCAAAG")
assert normalizer.reanchor(raw).window_is_final is False
assert normalizer.reanchor(raw, end=len(SEQUENCE)).window_is_final is True
def test_reanchor_refuses_to_leave_the_sequence(normalizer):
pair = normalizer.to_sequences(normalizer.parse("TEMPLATE:n.13del"), pad=2)
whole = normalizer.reanchor(pair, start=1, end=len(SEQUENCE))
assert (whole.position, whole.end) == (1, len(SEQUENCE))
assert whole.reference == SEQUENCE
with pytest.raises(ferro_hgvs.NormalizationError):
normalizer.reanchor(pair, start=1, end=len(SEQUENCE) + 1)
with pytest.raises(ferro_hgvs.NormalizationError):
normalizer.reanchor(pair, start=0)
def test_reanchor_moves_a_windows_edges_and_does_not_relocate_it(normalizer):
pair = normalizer.to_sequences(normalizer.parse("TEMPLATE:n.13del"), pad=2)
with pytest.raises(ferro_hgvs.NormalizationError, match="does not overlap") as excinfo:
normalizer.reanchor(pair, start=30, end=len(SEQUENCE))
assert "trim_to" not in str(excinfo.value)
partial = normalizer.reanchor(pair, start=13, end=30)
assert (partial.position, partial.end) == (13, 30)
assert str(partial.derive().variant) == "TEMPLATE:g.15del"
def test_reanchor_upper_cases_the_window_it_returns(normalizer):
masked = ferro_hgvs.SequencePair("TEMPLATE", 10, "gcaaaag", "GCAAAG")
widened = normalizer.reanchor(masked, start=5, end=25)
assert widened.reference == SEQUENCE[4:25]
assert widened.alternate == widened.alternate.upper()
def test_reads_anchored_to_one_region_agree():
seq = "GGATTACAGGCAAAAGCCTGAGGATTACAGGCATTAGCCT"
def read(lo, hi):
reference = seq[lo - 1 : hi]
at = reference.index("A", 12 - lo)
return ferro_hgvs.SequencePair("NC_1", lo, reference, reference[:at] + reference[at + 1 :])
reads = [read(9, 16), read(10, 17), read(11, 18)]
anchored = {
str(ferro_hgvs.from_sequences("NC_1", p.position, p.reference, p.alternate))
for p in (r.trim_to(start=11, end=14) for r in reads)
}
assert anchored == {"NC_1:g.14del"}
def test_a_pair_derives_from_itself():
pair = ferro_hgvs.SequencePair("NC_1", 10, "GCAAAAG", "GCAAAG")
assert str(pair.derive().variant) == "NC_1:g.15del"
bounded = pair.trim_to(end=14)
derived = bounded.derive()
assert str(derived.variant) == "NC_1:g.14del"
assert derived.placement_bounded_by_window is True
with pytest.raises(ValueError, match="max_grid_cells must be positive"):
pair.derive(max_grid_cells=0)
with pytest.raises(TypeError, match="unexpected keyword argument 'direction'"):
pair.derive(direction="5prime")
def test_reanchor_widens_a_pair_whose_alternate_is_empty(normalizer):
pair = ferro_hgvs.SequencePair("TEMPLATE", 12, "AAAA", "")
assert (pair.position, pair.end) == (12, 15)
identity = normalizer.reanchor(pair, start=12, end=15)
assert (identity.reference, identity.alternate) == ("AAAA", "")
widened = normalizer.reanchor(pair, start=9, end=20)
assert (widened.position, widened.end) == (9, 20)
assert widened.reference == SEQUENCE[8:20]
assert widened.alternate == SEQUENCE[8:11] + SEQUENCE[15:20]
assert str(widened.derive().variant) == "TEMPLATE:g.12_15del"
def test_reanchor_narrowing_refusal_names_the_callers_window(normalizer):
pair = ferro_hgvs.SequencePair("TEMPLATE", 12, "AAAA", "")
with pytest.raises(ferro_hgvs.NormalizationError) as excinfo:
normalizer.reanchor(pair, start=13, end=20)
message = str(excinfo.value)
assert "reanchor(13, 20)" in message, message
assert "[12, 15]" in message, message
assert "would leave nothing" in message, message