Repository: cogent3/cogent3
Pull request #2318 (2025-05-19 05:10:04)

Consistent behaviour with bytes moltype
## Summary by Sourcery

Ensure consistent behavior with bytes moltype by refactoring counting to use numpy, adjusting HTML gap handling, and expanding test coverage for bytes-based sequences and alignments

New Features:
- Add support for bytes moltype in sequence counting and HTML rendering

Bug Fixes:
- Fix counts_per_seq and to_html methods to correctly handle sequences with bytes moltype

Enhancements:
- Refactor sequence counts to use numpy for unique motif counting and filtering
- Consolidate gap trimming logic in HTML rendering to handle absence of gap characters
- Update CSS generation to use f-strings for consistent style formatting

Tests:
- Add tests for counts_per_seq and to_html with bytes moltype for both sequences and alignments


Patch:
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index 36492796b..19417c886 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -4981,6 +4981,7 @@ class Alignment(SequenceCollection):
             motifs.update(c.keys())
             counts.append(c)
 
+        # if type motifs not same as type element in moltype
         if not exclude_unobserved:
             motifs.update(self.moltype.alphabet.get_kmer_alphabet(motif_length))
 
@@ -6603,14 +6604,20 @@ class Alignment(SequenceCollection):
         else:
             names, output = selected[:limit]._get_raw_pretty(name_order)
 
-        gaps = "".join(selected.moltype.gaps)
         refname = names[0]
         refseq = output[refname]
         seqlen = len(refseq)
-        start_gap = re.search(f"^[{gaps}]+", "".join(refseq))
-        end_gap = re.search(f"[{gaps}]+$", "".join(refseq))
-        start = 0 if start_gap is None else start_gap.end()
-        end = len(refseq) if end_gap is None else end_gap.start()
+
+        if selected.moltype.gaps:
+            gaps = "".join(selected.moltype.gaps)
+            start_gap = re.search(f"^[{gaps}]+", "".join(refseq))
+            end_gap = re.search(f"[{gaps}]+$", "".join(refseq))
+            start = 0 if start_gap is None else start_gap.end()
+            end = seqlen if end_gap is None else end_gap.start()
+        else:
+            start = 0
+            end = seqlen
+
         seq_style = []
         template = '<span class="%s">%%s</span>'
         styled_seqs = defaultdict(list)
diff --git a/src/cogent3/core/new_sequence.py b/src/cogent3/core/new_sequence.py
index 59985961a..6f71fc22e 100644
--- a/src/cogent3/core/new_sequence.py
+++ b/src/cogent3/core/new_sequence.py
@@ -340,13 +340,9 @@ class Sequence:
             warns if motif_length > 1 and alignment trimmed to produce
             motif columns
         """
-        # refactor: array
-
-        data = str(self)
+        data = numpy.array(self)
 
-        if motif_length == 1:
-            counts = CategoryCounter(data)
-        else:
+        if motif_length != 1:
             if warn and len(data) % motif_length != 0:
                 warnings.warn(
                     f"{self.name} length not divisible by {motif_length}, truncating",
@@ -354,25 +350,39 @@ class Sequence:
                 )
             limit = (len(data) // motif_length) * motif_length
             data = data[:limit]
+            data = data.reshape(-1, motif_length)
+
+        unique_values, counts = numpy.unique(data, axis=0, return_counts=True)
+        if motif_length == 1:
+            unique_values = unique_values[:, None]
 
-            counts = CategoryCounter(
-                data[i : i + motif_length] for i in range(0, limit, motif_length)
+        indices = numpy.zeros(unique_values.shape[0], dtype=bool)
+        if not allow_gap:
+            indices |= numpy.apply_along_axis(
+                self.moltype.is_gapped, axis=1, arr=unique_values
             )
 
-        exclude = []
-        if not include_ambiguity or not allow_gap:
-            is_degen = self.moltype.is_degenerate
-            is_gap = self.moltype.is_gapped
-            for motif in counts:
-                if (not include_ambiguity and is_degen(motif)) or (
-                    not allow_gap and is_gap(motif)
-                ):
-                    exclude.append(motif)
+        if not include_ambiguity:
+            indices |= numpy.apply_along_axis(
+                self.moltype.is_degenerate, axis=1, arr=unique_values
+            )
+
+        unique_values = unique_values[~indices]
+        counts = counts[~indices]
 
-        for motif in exclude:
-            del counts[motif]
+        result = {}
+        alpha = self.moltype.most_degen_alphabet()
+        # The following approach is used because of bytes moltype.
+        # We can't use the from_indices method on the bytes moltype
+        # as that creates a string, but individual elements are
+        # bytes, not all of which can be decoded. Thus we get an
+        # empty instance and use join.
+        monomer_type = type(alpha[0])()
+        for motif, count in zip(unique_values, counts, strict=True):
+            key = monomer_type.join([alpha[i] for i in motif])
+            result[key] = int(count)
 
-        return counts
+        return CategoryCounter(result)
 
     def count_ambiguous(self) -> int:
         """Returns the number of ambiguous characters in the sequence."""
@@ -835,13 +845,18 @@ class Sequence:
 
         seq = str(self)
         seq = seq if limit is None else seq[:limit]
-        gaps = "".join(self.moltype.gaps)
         seqlen = len(seq)
-        start_gap = re.search(f"^[{gaps}]+", "".join(seq))
-        end_gap = re.search(f"[{gaps}]+$", "".join(seq))
+        if self.moltype.gaps:
+            gaps = "".join(self.moltype.gaps)
+            start_gap = re.search(f"^[{gaps}]+", "".join(seq))
+            end_gap = re.search(f"[{gaps}]+$", "".join(seq))
+
+            start = 0 if start_gap is None else start_gap.end()
+            end = len(seq) if end_gap is None else end_gap.start()
+        else:
+            start = 0
+            end = len(seq)
 
-        start = 0 if start_gap is None else start_gap.end()
-        end = len(seq) if end_gap is None else end_gap.start()
         seq_style = []
         template = '<span class="%s">%%s</span>'
         styled_seq = []
@@ -881,9 +896,9 @@ class Sequence:
             ".c3seq tr:not(.num_row) td span {margin: 0 2px;}",
             ".c3seq tr:nth-child(even) {background: #f7f7f7;}",
             ".c3seq .num_row {background-color:rgba(161, 195, 209, 0.5) !important; border-top: solid 1px black; }",
-            ".c3seq .label { font-size: %dpt ; text-align: right !important; "
-            "color: black !important; padding: 0 4px; }" % font_size,
-            "\n".join([".c3seq " + style for style in css]),
+            f".c3seq .label {{ font-size: {font_size}pt ; text-align: right !important; "
+            "color: black !important; padding: 0 4px; }}",
+            "\n".join([f".c3seq {style}" for style in css]),
             "</style>",
             '<div class="c3seq">',
             "\n".join(table),


Test patch:
diff --git a/tests/test_core/test_new_alignment.py b/tests/test_core/test_new_alignment.py
index 184f73b96..deac7c830 100644
--- a/tests/test_core/test_new_alignment.py
+++ b/tests/test_core/test_new_alignment.py
@@ -1891,6 +1891,16 @@ def test_counts_per_seq_text_moltype():
     assert got.col_sum()["T"] == 2
 
 
+def test_counts_per_seq_bytes_moltype():
+    """produce correct counts per seq with text moltypes"""
+    data = {"a": "AAAA??????", "b": "CCCGGG--NN", "c": "CCGGTTCCAA"}
+    coll = new_alignment.make_aligned_seqs(data, moltype="bytes")
+    got = coll.counts_per_seq(include_ambiguity=True, allow_gap=True)
+    assert got.col_sum()[b"-"] == 2
+    assert got.col_sum()[b"?"] == 6
+    assert got.col_sum()[b"T"] == 2
+
+
 def test_counts_per_pos_text_moltype():
     """produce correct counts per pos with default moltypes"""
     data = {"a": "AAAA??????", "b": "CCCGGG--NN", "c": "CCGGTTCCAA"}
@@ -3877,6 +3887,15 @@ def test_alignment_to_html_text_moltype():
     assert other_row in got
 
 
+def test_alignment_to_html_bytes_moltype():
+    """exercising producing html for text moltype"""
+    seqs = {"seq1": "ACG", "seq2": "-CT"}
+
+    aln = new_alignment.make_aligned_seqs(seqs, moltype="bytes")
+    got = aln.to_html(ref_name="longest")
+    assert isinstance(got, str)
+
+
 def test_alignment_repr():
     data = {
         "ENSMUSG00000056468": "GCCAGGGGGAAAA",
diff --git a/tests/test_core/test_new_sequence.py b/tests/test_core/test_new_sequence.py
index 41842f343..3cfa1e8a3 100644
--- a/tests/test_core/test_new_sequence.py
+++ b/tests/test_core/test_new_sequence.py
@@ -3083,3 +3083,9 @@ def test_sample_without_replacement():
     seq = cogent3.get_dataset("brca1")["Human"].seq
     with pytest.raises(ValueError):
         seq[:20].sample(n=100, with_replacement=False)
+
+
+def test_to_html_bytes():
+    seq = cogent3.make_seq("AGTACACTGGT", moltype="bytes", new_type=True)
+    html = seq.to_html()  # should not fail
+    assert isinstance(html, str)