Repository: biopython/biopython

Issue:
translate method for SeqRecord
We have a ``.reverse_complement(...)`` method in the ``SeqRecord``, using the method of the same name on the ``Seq`` object.

It would be natural to do this for ``.translate(...)`` as well, with similar behaviour for the metadata. i.e. default to dropping it, but make it easy to explicitly retain it (e.g. ``.translate(..., id=True)`` to retain the nucleotide record's ``.id`` on the protein record.

This would have been useful in some recent training course materials.


Discussion:

Resolving patch:
diff --git a/Bio/SeqRecord.py b/Bio/SeqRecord.py
--- a/Bio/SeqRecord.py
+++ b/Bio/SeqRecord.py
@@ -1159,6 +1159,98 @@ def reverse_complement(self, id=False, name=False, description=False,
                 answer._per_letter_annotations[key] = value[::-1]
         return answer
 
+    def translate(self,
+                  # Seq translation arguments:
+                  table="Standard", stop_symbol="*", to_stop=False,
+                  cds=False, gap=None,
+                  # SeqRecord annotation arguments:
+                  id=False, name=False, description=False,
+                  features=False, annotations=False,
+                  letter_annotations=False, dbxrefs=False):
+        """Returns new SeqRecord with translated sequence.
+
+        This calls the record's .seq.translate() method (which describes
+        the translation related arguments, like table for the genetic code),
+
+        By default the new record does NOT preserve the sequence identifier,
+        name, description, general annotation or database cross-references -
+        these are unlikely to apply to the translated sequence.
+
+        You can specify the returned record's id, name and description as
+        strings, or True to keep that of the parent, or False for a default.
+
+        You can specify the returned record's features with a list of
+        SeqFeature objects, or False (default) to omit them.
+
+        You can also specify both the returned record's annotations and
+        letter_annotations as dictionaries, True to keep that of the parent
+        (annotations only), or False (default) to omit them.
+
+        e.g. Loading a FASTA gene and translating it,
+
+        >>> from Bio import SeqIO
+        >>> gene_record = SeqIO.read("Fasta/sweetpea.nu", "fasta")
+        >>> print(gene_record.format("fasta"))
+        >gi|3176602|gb|U78617.1|LOU78617 Lathyrus odoratus phytochrome A (PHYA) gene, partial cds
+        CAGGCTGCGCGGTTTCTATTTATGAAGAACAAGGTCCGTATGATAGTTGATTGTCATGCA
+        AAACATGTGAAGGTTCTTCAAGACGAAAAACTCCCATTTGATTTGACTCTGTGCGGTTCG
+        ACCTTAAGAGCTCCACATAGTTGCCATTTGCAGTACATGGCTAACATGGATTCAATTGCT
+        TCATTGGTTATGGCAGTGGTCGTCAATGACAGCGATGAAGATGGAGATAGCCGTGACGCA
+        GTTCTACCACAAAAGAAAAAGAGACTTTGGGGTTTGGTAGTTTGTCATAACACTACTCCG
+        AGGTTTGTT
+        <BLANKLINE>
+
+        And now translating the record, specifying the new ID and description:
+
+        >>> protein_record = gene_record.translate(table=11,
+        ...                                        id="phya",
+        ...                                        description="translation")
+        >>> print(protein_record.format("fasta"))
+        >phya translation
+        QAARFLFMKNKVRMIVDCHAKHVKVLQDEKLPFDLTLCGSTLRAPHSCHLQYMANMDSIA
+        SLVMAVVVNDSDEDGDSRDAVLPQKKKRLWGLVVCHNTTPRFV
+        <BLANKLINE>
+
+        """
+        answer = SeqRecord(self.seq.translate(table=table,
+                                              stop_symbol=stop_symbol,
+                                              to_stop=to_stop,
+                                              cds=cds,
+                                              gap=gap))
+        if isinstance(id, basestring):
+            answer.id = id
+        elif id:
+            answer.id = self.id
+        if isinstance(name, basestring):
+            answer.name = name
+        elif name:
+            answer.name = self.name
+        if isinstance(description, basestring):
+            answer.description = description
+        elif description:
+            answer.description = self.description
+        if isinstance(dbxrefs, list):
+            answer.dbxrefs = dbxrefs
+        elif dbxrefs:
+            # Copy the old dbxrefs
+            answer.dbxrefs = self.dbxrefs[:]
+        if isinstance(features, list):
+            answer.features = features
+        elif features:
+            # Does not make sense to copy old features as locations wrong
+            raise TypeError("Unexpected features argument %r" % features)
+        if isinstance(annotations, dict):
+            answer.annotations = annotations
+        elif annotations:
+            # Copy the old annotations
+            answer.annotations = self.annotations.copy()
+        if isinstance(letter_annotations, dict):
+            answer.letter_annotations = letter_annotations
+        elif letter_annotations:
+            # Does not make sense to copy these as length now wrong
+            raise TypeError("Unexpected letter_annotations argument %r" % letter_annotations)
+        return answer
+
 
 if __name__ == "__main__":
     from Bio._utils import run_doctest


Test patch:
diff --git a/Tests/test_SeqRecord.py b/Tests/test_SeqRecord.py
--- a/Tests/test_SeqRecord.py
+++ b/Tests/test_SeqRecord.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2016 by Peter Cock.  All rights reserved.
+# Copyright 2009-2017 by Peter Cock.  All rights reserved.
 # This code is part of the Biopython distribution and governed by its
 # license.  Please see the LICENSE file that should have been included
 # as part of this package.
@@ -303,6 +303,11 @@ def test_slice_add_shift(self):
             self.assertEqual(rec.letter_annotations, {"fake": "X" * 26})
             self.assertTrue(len(rec.features) <= len(self.record.features))
 
+
+class SeqRecordMethodsMore(unittest.TestCase):
+    """Test SeqRecord methods cont."""
+    # This class does not have a setUp defining self.record
+
     def test_reverse_complement_seq(self):
         s = SeqRecord(Seq("ACTG"), id="TestID", name="TestName",
                       description="TestDescription", dbxrefs=["TestDbxrefs"],
@@ -346,6 +351,34 @@ def test_reverse_complement_mutable_seq(self):
         s = SeqRecord(MutableSeq("ACTG"))
         self.assertEqual("CAGT", str(s.reverse_complement().seq))
 
+    def test_translate(self):
+        s = SeqRecord(Seq("ATGGTGTAA"), id="TestID", name="TestName",
+                      description="TestDescription", dbxrefs=["TestDbxrefs"],
+                      features=[SeqFeature(FeatureLocation(0, 3), type="Site")],
+                      annotations={'organism': 'bombyx'},
+                      letter_annotations={'test': 'abcdefghi'})
+
+        t = s.translate()
+        self.assertEqual(t.seq, "MV*")
+        self.assertEqual(t.id, "<unknown id>")
+        self.assertEqual(t.name, "<unknown name>")
+        self.assertEqual(t.description, "<unknown description>")
+        self.assertFalse(t.dbxrefs)
+        self.assertFalse(t.features)
+        self.assertFalse(t.annotations)
+        self.assertFalse(t.letter_annotations)
+
+        t = s.translate(cds=True, id=True, name=True, description=True,
+                        dbxrefs=True, annotations=True)
+        self.assertEqual(t.seq, "MV")
+        self.assertEqual(t.id, "TestID")
+        self.assertEqual(t.name, "TestName")
+        self.assertEqual(t.description, "TestDescription")
+        self.assertEqual(t.dbxrefs, ["TestDbxrefs"])
+        self.assertFalse(t.features)
+        self.assertEqual(t.annotations, {'organism': 'bombyx'})
+        self.assertFalse(t.letter_annotations)
+
     def test_lt_exception(self):
         def lt():
             SeqRecord(Seq("A")) < SeqRecord(Seq("A"))
@@ -377,6 +410,58 @@ def ge():
         self.assertRaises(NotImplementedError, ge)
 
 
+class TestTranslation(unittest.TestCase):
+
+    def setUp(self):
+        self.s = SeqRecord(Seq("ATGGTGTAA"), id="TestID", name="TestName",
+                           description="TestDescription", dbxrefs=["TestDbxrefs"],
+                           features=[SeqFeature(FeatureLocation(0, 3), type="Site")],
+                           annotations={'organism': 'bombyx'},
+                           letter_annotations={'test': 'abcdefghi'})
+
+    def test_defaults(self):
+        t = self.s.translate()
+        self.assertEqual(t.seq, "MV*")
+        self.assertEqual(t.id, "<unknown id>")
+        self.assertEqual(t.name, "<unknown name>")
+        self.assertEqual(t.description, "<unknown description>")
+        self.assertFalse(t.dbxrefs)
+        self.assertFalse(t.features)
+        self.assertFalse(t.annotations)
+        self.assertFalse(t.letter_annotations)
+
+    def test_preserve(self):
+        t = self.s.translate(cds=True, id=True, name=True, description=True,
+                        dbxrefs=True, annotations=True)
+        self.assertEqual(t.seq, "MV")
+        self.assertEqual(t.id, "TestID")
+        self.assertEqual(t.name, "TestName")
+        self.assertEqual(t.description, "TestDescription")
+        self.assertEqual(t.dbxrefs, ["TestDbxrefs"])
+        self.assertFalse(t.features)
+        self.assertEqual(t.annotations, {'organism': 'bombyx'})
+        self.assertFalse(t.letter_annotations)
+
+        # Should not preserve these
+        self.assertRaises(TypeError, self.s.translate, features=True)
+        self.assertRaises(TypeError, self.s.translate, letter_annotations=True)
+
+    def test_new_annot(self):
+        t = self.s.translate(1, to_stop=True, gap="-",
+                             id="Foo", name="Bar", description="Baz", dbxrefs=["Nope"],
+                             features=[SeqFeature(FeatureLocation(0, 3), type="Site")],
+                             annotations={"a": "team"},
+                             letter_annotations={"aa": ["Met", "Val"]})
+        self.assertEqual(t.seq, "MV")
+        self.assertEqual(t.id, "Foo")
+        self.assertEqual(t.name, "Bar")
+        self.assertEqual(t.description, "Baz")
+        self.assertEqual(t.dbxrefs, ["Nope"])
+        self.assertEqual(len(t.features), 1)
+        self.assertEqual(t.annotations, {'a': 'team'})
+        self.assertEqual(t.letter_annotations, {"aa": ["Met", "Val"]})
+
+
 if __name__ == "__main__":
     runner = unittest.TextTestRunner(verbosity=2)
     unittest.main(testRunner=runner)