Repository: cogent3/cogent3
Pull request #1532 (2023-07-12 00:33:00)

API: added strict argument to has_terminal_stop() method
[NEW] if seq / align length not divisible by 3, raises an exception

[CHANGED] deprecated allow_partial argument

[NEW] <collection>.has_terminal_stops() is being deprecated for
    <collection>.has_terminal_stop(), because it returns True if a single
    sequence has a terminal stop.


Patch:
diff --git a/changelog.d/20230712_102325_Gavin.Huttley.md b/changelog.d/20230712_102325_Gavin.Huttley.md
new file mode 100644
index 000000000..36a459ba0
--- /dev/null
+++ b/changelog.d/20230712_102325_Gavin.Huttley.md
@@ -0,0 +1,44 @@
+<!--
+A new scriv changelog fragment.
+
+Uncomment the section that is right (remove the HTML comment wrapper).
+-->
+
+<!--
+### Contributors
+
+- A bullet item for the Contributors category.
+
+-->
+<!--
+### ENH
+
+- A bullet item for the ENH category.
+
+-->
+<!--
+### BUG
+
+- A bullet item for the BUG category.
+
+-->
+<!--
+### DOC
+
+- A bullet item for the DOC category.
+
+-->
+
+### Deprecations
+
+- <collection>.has_terminal_stops() is being deprecated for
+  <collection>.has_terminal_stop(), because it returns True if a single
+  sequence has a terminal stop.
+
+
+<!--
+### Discontinued
+
+- A bullet item for the Discontinued category.
+
+-->
diff --git a/src/cogent3/core/alignment.py b/src/cogent3/core/alignment.py
index 61a5c2bd2..2b398bad9 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -31,7 +31,7 @@ from copy import deepcopy
 from functools import total_ordering
 from itertools import combinations
 from types import GeneratorType
-from typing import Iterable, Iterator, List, Optional, Tuple, Union
+from typing import Any, Iterable, Iterator, List, Optional, Tuple, Union
 
 import numpy
 
@@ -74,7 +74,6 @@ from cogent3.format.fasta import alignment_to_fasta
 from cogent3.format.nexus import nexus_from_alignment
 from cogent3.format.phylip import alignment_to_phylip
 from cogent3.maths.stats.number import CategoryCounter
-from cogent3.maths.util import safe_log
 from cogent3.parse.gff import gff_parser
 from cogent3.util import progress_display as UI
 from cogent3.util import warning as c3warn
@@ -1424,27 +1423,36 @@ class _SequenceCollectionBase:
             result.annotation_db = self.annotation_db
         return result
 
-    def has_terminal_stops(self, gc=None, allow_partial=False):
+    @c3warn.deprecated_args(
+        "2023.10", "replaced by strict", discontinued="allow_partial"
+    )
+    def has_terminal_stop(
+        self, gc: Any = None, strict: bool = False, allow_partial=False
+    ) -> bool:
         """Returns True if any sequence has a terminal stop codon.
 
         Parameters
         ----------
         gc
-            genetic code object
-        allow_partial
-            if True and the sequence length is not divisible
-            by 3, ignores the 3' terminal incomplete codon
-
+            valid input to cogent3.get_code(), a genetic code object, number
+            or name
+        strict
+            If True, raises an exception if a seq length not divisible by 3
         """
-        stops = []
         aligned = isinstance(self, Alignment)
         for seq_name in self.names:
             if aligned:
                 seq = self.named_seqs[seq_name].data
             else:
                 seq = self.named_seqs[seq_name]
-            stops.append(seq.has_terminal_stop(gc=gc, allow_partial=allow_partial))
-        return max(stops)
+            if seq.has_terminal_stop(gc=gc, strict=strict):
+                return True
+        return False
+
+    @c3warn.deprecated_callable("2023.10", "better name", new="has_terminal_stop")
+    def has_terminal_stops(self, **kwargs) -> bool:
+        """deprecated"""
+        return self.has_terminal_stop(**kwargs)
 
     def trim_stop_codons(self, gc=None, allow_partial=False, **kwargs):
         """Removes any terminal stop codons from the sequences
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index f50fe5d15..80b2c4d65 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -23,7 +23,7 @@ from collections import defaultdict
 from functools import singledispatch, total_ordering
 from operator import eq, ne
 from random import shuffle
-from typing import Generator, Iterable, List, Optional, Tuple
+from typing import Any, Generator, Iterable, List, Optional, Tuple
 
 from numpy import (
     arange,
@@ -1661,29 +1661,30 @@ class NucleicAcidSequence(Sequence):
         rc.annotation_db = self.annotation_db
         return rc
 
-    def has_terminal_stop(self, gc=None, allow_partial=False):
+    @deprecated_args("2023.10", "replaced by strict", discontinued="allow_partial")
+    def has_terminal_stop(
+        self, gc: Any = None, strict: bool = False, allow_partial=False
+    ) -> bool:
         """Return True if the sequence has a terminal stop codon.
 
         Parameters
         ----------
         gc
-            genetic code object
-        allow_partial
-            if True and the sequence length is not dividisble
-            by 3, ignores the 3' terminal incomplete codon
-
+            valid input to cogent3.get_code(), a genetic code object, number
+            or name
+        strict
+            If True, raises an exception if length not divisible by 3
         """
         gc = get_code(gc)
-        codons = self._seq
-        divisible_by_3 = len(codons) % 3 == 0
-        end3 = self.__class__(self._seq[-3:]).degap()
-        if not allow_partial and (not divisible_by_3 or len(end3) != 3):
-            raise ValueError("seq length not divisible by 3")
+        divisible_by_3 = len(self) % 3 == 0
+        if divisible_by_3:
+            end3 = str(self[-3:])
+            return gc.is_stop(end3)
 
-        if not divisible_by_3:
-            return False
+        if strict:
+            raise ValueError(f"{self.name!r} length not divisible by 3")
 
-        return codons and gc.is_stop(codons[-3:])
+        return False
 
     def trim_stop_codon(self, gc=None, allow_partial=False):
         """Removes a terminal stop codon from the sequence


Test patch:
diff --git a/tests/test_core/test_alignment.py b/tests/test_core/test_alignment.py
index 28864b731..78ad9574a 100644
--- a/tests/test_core/test_alignment.py
+++ b/tests/test_core/test_alignment.py
@@ -3594,3 +3594,35 @@ def test_aligned_rich_dict(reverse):
     rd = seq.to_rich_dict()
     got = Aligned.from_rich_dict(rd)
     assert str(seq) == str(got)
+
+
+@pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
+@pytest.mark.parametrize(
+    "gc,seqs", ((1, ("TCCTGA", "GATTT?")), (2, ("GATTTT", "TCCAGG")))
+)
+def test_has_terminal_stop_true(cls, gc, seqs):
+    gc = get_code(gc)
+    data = {f"s{i}": s for i, s in enumerate(seqs)}
+    seqs = cls(data=data, moltype="dna")
+    assert seqs.has_terminal_stop(gc=gc)
+
+
+@pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
+@pytest.mark.parametrize(
+    "gc,seqs",
+    ((1, ("TCCTCA", "GATTTT")), (2, ("GATTTT", "TCCCGG")), (1, ("CCTCA", "ATTTT"))),
+)
+def test_has_terminal_stop_false(cls, gc, seqs):
+    gc = get_code(gc)
+    data = {f"s{i}": s for i, s in enumerate(seqs)}
+    seqs = cls(data=data, moltype="dna")
+    assert not seqs.has_terminal_stop(gc=gc)
+
+
+@pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
+def test_has_terminal_stop_strict(cls):
+    gc = get_code(1)
+    data = {f"s{i}": s for i, s in enumerate(("CCTCA", "ATTTT"))}
+    seqs = cls(data=data, moltype="dna")
+    with pytest.raises(ValueError):
+        seqs.has_terminal_stop(gc=gc, strict=True)
diff --git a/tests/test_core/test_core_standalone.py b/tests/test_core/test_core_standalone.py
index a939c190e..c7782d201 100644
--- a/tests/test_core/test_core_standalone.py
+++ b/tests/test_core/test_core_standalone.py
@@ -760,38 +760,6 @@ class AlignmentTestMethods(unittest.TestCase):
         aln = aln.trim_stop_codons()
         self.assertEqual(aln.info["key"], "value")
 
-    def test_has_terminal_stops(self):
-        """test truth values for terminal stops"""
-        # seq collections
-        seq_coll = make_unaligned_seqs(
-            data={"seq1": "ACGTAA", "seq2": "ACG", "seq3": "ACGCGT"}, moltype=DNA
-        )
-        assert seq_coll.has_terminal_stops() == True
-        seq_coll = make_unaligned_seqs(
-            data={"seq1": "ACGTAC", "seq2": "ACGACG", "seq3": "ACGCGT"}, moltype=DNA
-        )
-        assert seq_coll.has_terminal_stops() == False
-        # alignments
-        aln = make_aligned_seqs(
-            data={"seq1": "ACGTAA", "seq2": "ACGCAA", "seq3": "ACGCGT"}, moltype=DNA
-        )
-        assert aln.has_terminal_stops() == True
-        aln = make_aligned_seqs(
-            data={"seq1": "ACGTAA", "seq2": "ACGTAG", "seq3": "ACGTGA"}, moltype=DNA
-        )
-        assert aln.has_terminal_stops() == True
-        aln = make_aligned_seqs(
-            data={"seq1": "ACGCAA", "seq2": "ACGCAA", "seq3": "ACGCGT"}, moltype=DNA
-        )
-        assert aln.has_terminal_stops() == False
-
-        # ValueError if ragged end
-        aln = make_aligned_seqs(
-            data={"seq1": "ACGCAA", "seq2": "ACGTAA", "seq3": "ACGCG-"}, moltype=DNA
-        )
-        self.assertRaises(ValueError, aln.has_terminal_stops)
-        self.assertTrue(aln.has_terminal_stops(allow_partial=True))
-
     def test_slice(self):
         seqs = {"seq1": "ACGTANGT", "seq2": "ACGTACGT", "seq3": "ACGTACGT"}
         alignment = make_aligned_seqs(data=seqs)
@@ -956,20 +924,6 @@ class SequenceTestMethods(unittest.TestCase):
         # unless explicitly over-ride length issue using allow_partial
         seq2 = seq.trim_stop_codon(allow_partial=True)
 
-    def test_has_terminal_stop(self):
-        """test check for terminal stop codons"""
-        seq = make_seq(moltype=DNA, seq="ACTTAA")
-        assert seq.has_terminal_stop() == True
-        seq = make_seq(moltype=DNA, seq="ACTTAT") == False
-
-        # for sequence not divisible by 3
-        seq = make_seq(moltype=DNA, seq="ACTTA")
-        # fail
-        self.assertRaises(ValueError, seq.has_terminal_stop)
-        # unless explicitly over-ride length issue using allow_partial
-        # in which case, returns False
-        self.assertFalse(seq.has_terminal_stop(allow_partial=True))
-
 
 def test_load_seq_new():
     """load single sequence"""
diff --git a/tests/test_core/test_sequence.py b/tests/test_core/test_sequence.py
index 371a00e9d..b129af45f 100644
--- a/tests/test_core/test_sequence.py
+++ b/tests/test_core/test_sequence.py
@@ -2388,3 +2388,26 @@ def test_get_drawable():
     # and their names should indicate they're incomplete
     for trace in full.traces:
         assert "(incomplete)" in trace.text
+
+
+@pytest.mark.parametrize("gc,seq", ((1, "TCCTGA"), (2, "TCCAGG")))
+def test_has_terminal_stop_true(gc, seq):
+    gc = cogent3.get_code(gc)
+    seq = cogent3.make_seq(seq, moltype="dna")
+    assert seq.has_terminal_stop(gc=gc)
+
+
+@pytest.mark.parametrize(
+    "gc,seq", ((1, "TCCAGG"), (2, "TCCAAA"), (1, "CCTGA"), (2, "CCAGG"))
+)
+def test_has_terminal_stop_false(gc, seq):
+    gc = cogent3.get_code(gc)
+    seq = cogent3.make_seq(seq, moltype="dna")
+    assert not seq.has_terminal_stop(gc=gc)
+
+
+def test_has_terminal_stop_strict():
+    gc = cogent3.get_code(1)
+    seq = cogent3.make_seq("TCCAG", moltype="dna")
+    with pytest.raises(ValueError):
+        seq.has_terminal_stop(gc=gc, strict=True)