Repository: cogent3/cogent3
Pull request #2157 (2024-12-05 06:33:45)

DEV: more mods to facilitate new_type intergration
## Summary by Sourcery

Integrate new coercion functionality for RNA and DNA sequences by adding coercion functions and updating the MolType class. Refactor sequence creation in parsers to improve clarity and consistency, and add tests to verify the new coercion features.

New Features:
- Introduce coercion functions for RNA and DNA sequences to facilitate conversion between these types.

Enhancements:
- Add an optional 'coerce_to' attribute to the MolType class to enable automatic sequence conversion for nucleic acid types.
- Refactor sequence creation in various parsers to use keyword arguments for clarity and consistency.

Tests:
- Add tests for the new coercion functionality in MolType to ensure correct conversion between RNA and DNA sequences.


Patch:
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index 6af267cbe..f86f70a74 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -6459,6 +6459,10 @@ class Alignment(SequenceCollection):
         data["init_args"].pop("annotation_db", None)
         return make_aligned_seqs(data["seqs"], **data["init_args"])
 
+    def to_type(self, **kwargs):  # pragma: no cover
+        # TODO remove when new_type is the default
+        return self
+
 
 @register_deserialiser(get_object_provenance(Alignment))
 def deserialise_alignment(data: dict[str, str | dict[str, str]]) -> Alignment:
diff --git a/src/cogent3/core/new_moltype.py b/src/cogent3/core/new_moltype.py
index d14f05fe1..d477161f6 100644
--- a/src/cogent3/core/new_moltype.py
+++ b/src/cogent3/core/new_moltype.py
@@ -18,6 +18,8 @@ StrORBytes = typing.Union[str, bytes]
 StrORArray = typing.Union[str, numpy.ndarray]
 StrORBytesORArray = typing.Union[str, bytes, numpy.ndarray]
 SeqStrBytesType = typing.Union[list[StrORBytes], tuple[StrORBytes, ...]]
+OptTranslater = typing.Optional[typing.Callable[[bytes], bytes]]
+
 
 IUPAC_gap = "-"
 
@@ -370,6 +372,10 @@ def _strictly_upper(monomers: tuple[StrORBytes]):
     return all(cast(c).isupper() for c in monomers)
 
 
+coerce_to_rna = new_alphabet.convert_alphabet(b"tT", b"uU")
+coerce_to_dna = new_alphabet.convert_alphabet(b"uU", b"tT")
+
+
 @dataclasses.dataclass
 class MolType:
     """MolType handles operations that depend on the sequence type.
@@ -392,6 +398,7 @@ class MolType:
     colors: dataclasses.InitVar[dict[str, str] | None] = None
     pairing_rules: dict[str, dict[frozenset[str], bool]] | None = None
     mw_calculator: WeightCalculator | None = None
+    coerce_to: OptTranslater | None = None
 
     # private attributes to be delivered via properties
     _monomers: new_alphabet.CharAlphabet = dataclasses.field(init=False)
@@ -588,10 +595,19 @@ class MolType:
         **kwargs
             additional keyword arguments that may be required for creating the
             Sequence object
+
+        Notes
+        -----
+        If seq is a string, and the moltype has a coerce_to attribute, the
+        string will be converted via that callable into a character set
+        compatible with the moltype. Only applies to nucleic acid moltypes.
         """
         if hasattr(seq, "moltype"):
             return seq if seq.moltype is self else seq.to_moltype(self)
 
+        if isinstance(seq, str):
+            seq = self.coerce_to(seq.encode("utf8")) if self.coerce_to else seq
+
         if check_seq:
             assert self.is_valid(
                 seq,
@@ -1313,6 +1329,7 @@ DNA = MolType(
     make_seq=new_sequence.DnaSequence,
     pairing_rules=DNA_STANDARD_PAIRS,
     mw_calculator=DnaMW,
+    coerce_to=coerce_to_dna,
 )
 
 RNA = MolType(
@@ -1324,6 +1341,7 @@ RNA = MolType(
     make_seq=new_sequence.RnaSequence,
     pairing_rules=RNA_STANDARD_PAIRS,
     mw_calculator=RnaMW,
+    coerce_to=coerce_to_rna,
 )
 PROTEIN = MolType(
     monomers="".join(IUPAC_PROTEIN_chars),
diff --git a/src/cogent3/parse/cigar.py b/src/cogent3/parse/cigar.py
index fa9cecd63..4550890d2 100644
--- a/src/cogent3/parse/cigar.py
+++ b/src/cogent3/parse/cigar.py
@@ -55,7 +55,7 @@ def aligned_from_cigar(cigar_text, seq, moltype="dna"):
     """returns an Aligned sequence from a cigar string, sequence and moltype"""
     moltype = cogent3.get_moltype(moltype)
     if isinstance(seq, str):
-        seq = moltype.make_seq(seq)
+        seq = moltype.make_seq(seq=seq)
     map = cigar_to_map(cigar_text)
     return seq.gapped_by_map(map)
 
@@ -152,7 +152,7 @@ def CigarParser(
                 start, stop = seq_loc
                 seq = seqs[seqname][start:stop]
                 if isinstance(seq, str):
-                    seq = moltype.make_seq(seq)
+                    seq = moltype.make_seq(seq=seq)
                 data[seqname] = seq.gapped_by_map(m)
             else:
                 data[seqname] = moltype.make_seq("-" * (aln_loc[1] - aln_loc[0]))
diff --git a/src/cogent3/parse/fasta.py b/src/cogent3/parse/fasta.py
index 348e6de6a..17976edcb 100644
--- a/src/cogent3/parse/fasta.py
+++ b/src/cogent3/parse/fasta.py
@@ -13,7 +13,7 @@ import numpy
 
 import cogent3
 from cogent3.core.info import Info
-from cogent3.core.moltype import ASCII, BYTES
+from cogent3.core.moltype import BYTES
 from cogent3.parse.record import RecordError
 from cogent3.parse.record_finder import LabeledRecordFinder
 from cogent3.util.io import is_url, open_
@@ -337,7 +337,7 @@ def GroupFastaParser(
     label_to_name,
     group_key="Group",
     aligned=False,
-    moltype=ASCII,
+    moltype="text",
     done_groups=None,
     DEBUG=False,
 ):
@@ -359,13 +359,13 @@ def GroupFastaParser(
         series of group keys to be excluded
 
     """
-
+    moltype = cogent3.get_moltype(moltype)
     done_groups = [[], done_groups][done_groups is not None]
     parser = MinimalFastaParser(data, label_to_name=label_to_name)
     group_ids = []
     current_collection = {}
     for label, seq in parser:
-        seq = moltype.make_seq(seq, name=label, info=label.info)
+        seq = moltype.make_seq(seq=seq, name=label, info=label.info)
         if DEBUG:
             print(f"{label=} {label=!r}")
         if not group_ids:
diff --git a/src/cogent3/parse/genbank.py b/src/cogent3/parse/genbank.py
index 5a3036a18..cad0527a9 100644
--- a/src/cogent3/parse/genbank.py
+++ b/src/cogent3/parse/genbank.py
@@ -10,7 +10,6 @@ import cogent3
 from cogent3.core import new_alphabet
 from cogent3.core.annotation_db import GenbankAnnotationDb
 from cogent3.core.info import Info
-from cogent3.core.moltype import get_moltype
 from cogent3.parse.record import FieldWrapper
 from cogent3.parse.record_finder import (
     DelimitedRecordFinder,
@@ -766,7 +765,7 @@ def rich_parser(
         does not create an annotation_db. Overrides db argument.
     """
     info_excludes = info_excludes or ["sequence", "features"]
-    moltype = get_moltype(moltype) if moltype else None
+    moltype = cogent3.get_moltype(moltype) if moltype else None
     feature_parser = parse_metadata_first_line if just_seq else default_parse_metadata
     for rec in minimal_parser(
         handle,
@@ -783,7 +782,7 @@ def rich_parser(
             rec_moltype = (
                 rec_moltype if rec_moltype in ("dna", "rna", "protein") else "text"
             )
-            rec_moltype = get_moltype(rec_moltype)
+            rec_moltype = cogent3.get_moltype(rec_moltype)
         else:
             rec_moltype = moltype
 
diff --git a/src/cogent3/parse/phylip.py b/src/cogent3/parse/phylip.py
index 143682add..0b111ba64 100644
--- a/src/cogent3/parse/phylip.py
+++ b/src/cogent3/parse/phylip.py
@@ -1,5 +1,4 @@
-#!/usr/bin/env python
-from cogent3.core.alignment import Alignment
+import cogent3
 from cogent3.parse.record import RecordError
 
 
@@ -124,4 +123,4 @@ def get_align_for_phylip(data, id_map=None):
     tuples = []
     for tup in mpp:
         tuples.append(tup)
-    return Alignment(tuples)
+    return cogent3.make_aligned_seqs(tuples, moltype="text")


Test patch:
diff --git a/tests/test_core/test_new_moltype.py b/tests/test_core/test_new_moltype.py
index 8d877cc16..c3eb7ff84 100644
--- a/tests/test_core/test_new_moltype.py
+++ b/tests/test_core/test_new_moltype.py
@@ -633,3 +633,16 @@ def test_is_nucleic(moltype):
 def test_not_is_nucleic(moltype):
     mt = new_moltype.get_moltype(moltype)
     assert not mt.is_nucleic
+
+
+def test_moltype_coerce_seqs():
+    dna = new_moltype.get_moltype("dna")
+    rna_seq = "AUUG"
+    dna_seq = "ATTG"
+    rna = new_moltype.get_moltype("rna")
+    assert str(dna.make_seq(seq=rna_seq)) == dna_seq
+    assert str(rna.make_seq(seq=dna_seq)) == rna_seq
+
+    # no coercion in protein seq
+    prot = new_moltype.get_moltype("protein")
+    assert str(prot.make_seq(seq=rna_seq)) == rna_seq
diff --git a/tests/test_parse/test_genbank.py b/tests/test_parse/test_genbank.py
index 37a322b9f..0959bfbbd 100644
--- a/tests/test_parse/test_genbank.py
+++ b/tests/test_parse/test_genbank.py
@@ -545,7 +545,7 @@ def test_rich_genbank_just_seq():
         parser = genbank.rich_parser(infile, just_seq=True)
         seq = [s for l, s in parser][0]
 
-    assert not len(seq.annotation_db)
+    assert not seq.annotation_db
 
 
 @pytest.fixture(params=("\r\n", "\n"))
diff --git a/tests/test_parse/test_nexus.py b/tests/test_parse/test_nexus.py
index 6922fe904..7f7a7a3ee 100644
--- a/tests/test_parse/test_nexus.py
+++ b/tests/test_parse/test_nexus.py
@@ -403,10 +403,10 @@ class NexusParserTests(TestCase):
 
     def test_load_seqs_interface(self):
         """load_aligned_seqs correctly loads nexus alignments"""
-        aln = load_aligned_seqs("data/nexus_mixed.nex")
+        aln = load_aligned_seqs("data/nexus_mixed.nex", moltype="text")
         self.assertEqual(aln.num_seqs, 4)
         self.assertEqual(len(aln), 20)
 
-        aln = load_aligned_seqs("data/nexus_aa.nxs")
+        aln = load_aligned_seqs("data/nexus_aa.nxs", moltype="text")
         self.assertEqual(aln.num_seqs, 10)
         self.assertEqual(len(aln), 234)
diff --git a/tests/test_parse/test_sequence.py b/tests/test_parse/test_sequence.py
index ec0fe7df3..018a2c832 100644
--- a/tests/test_parse/test_sequence.py
+++ b/tests/test_parse/test_sequence.py
@@ -87,5 +87,5 @@ def phylip_file(DATA_DIR, tmp_path, request):
 def test_select_parser_phylip_suffixes(phylip_file):
     from cogent3 import load_aligned_seqs
 
-    got = load_aligned_seqs(phylip_file)
+    got = load_aligned_seqs(phylip_file, moltype="dna")
     assert set(got.names) == {"human", "chimp", "mouse"}