Repository: biopython/biopython

Issue:
Seq("X").join(single_string) fails
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

```python
>>> import sys; print(sys.version)
3.7.6 | packaged by conda-forge | (default, Jan  7 2020, 22:05:27) 
[Clang 9.0.1 ]
>>> import platform; print(platform.python_implementation()); print(platform.platform())
CPython
Darwin-19.4.0-x86_64-i386-64bit
>>> import Bio; print(Bio.__version__)
1.78.dev0
```

### Expected behaviour

The ``Seq`` object should match the Python string's join method with single string arguments:

```
>>> "X".join("a")
'a'
>>> "X".join("abc")
'aXbXc'
```

### Actual behaviour

```
>>> from Bio.Seq import Seq
>>> Seq("X").join("abc")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/pc40583/repositories/biopython/Bio/Seq.py", line 1244, in join
    raise ValueError("Input must be an iterable")
ValueError: Input must be an iterable
>>> Seq("X").join("a")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/pc40583/repositories/biopython/Bio/Seq.py", line 1244, in join
    raise ValueError("Input must be an iterable")
ValueError: Input must be an iterable
```

### Steps to reproduce

Given above. Noted by @chris-rands here: https://github.com/biopython/biopython/pull/2916#issuecomment-635193611


Discussion:
I suspect the reason I didn't implement this originally was the extra work to make ``Seq("X").join(Seq("abc"))`` respect the alphabets.

Resolving patch:
diff --git a/Bio/Seq.py b/Bio/Seq.py
--- a/Bio/Seq.py
+++ b/Bio/Seq.py
@@ -23,7 +23,6 @@
 import array
 import sys
 import warnings
-import collections.abc
 
 from Bio import BiopythonWarning
 from Bio import Alphabet
@@ -1231,23 +1230,35 @@ def ungap(self, gap="-"):
     def join(self, other):
         """Return a merge of the sequences in other, spaced by the sequence from self.
 
-        Accepts all Seq objects and Strings as objects to be concatenated with the spacer
+        Accepts either a Seq or string (and iterates over the letters), or an
+        iterable containing Seq or string objects. These arguments will be
+        concatenated with the calling sequence as the spacer:
 
         >>> concatenated = Seq('NNNNN').join([Seq("AAA"), Seq("TTT"), Seq("PPP")])
         >>> concatenated
         Seq('AAANNNNNTTTNNNNNPPP')
 
-        Throws error if other is not an iterable and if objects inside of the iterable
-        are not Seq or String objects
+        Joining the letters of a single sequence:
+
+        >>> Seq('NNNNN').join(Seq("ACGT"))
+        Seq('ANNNNNCNNNNNGNNNNNT')
+        >>> Seq('NNNNN').join("ACGT")
+        Seq('ANNNNNCNNNNNGNNNNNT')
         """
-        if not isinstance(other, collections.abc.Iterable):
-            # doesn't detect single strings
-            raise ValueError("Input must be an iterable")
-        if isinstance(other, str):
-            raise ValueError("Input must be an iterable")
         from Bio.SeqRecord import SeqRecord  # Lazy to avoid circular imports
 
         a = self.alphabet
+        if isinstance(other, (Seq, MutableSeq, UnknownSeq)):
+            if a != other.alphabet:
+                if not Alphabet._check_type_compatible([a, other.alphabet]):
+                    raise TypeError(
+                        f"Incompatible alphabets {a!r} and {other.alphabet!r}"
+                    )
+                a = Alphabet._consensus_alphabet([a, other.alphabet])
+            # No point looping over the seq and checking the alphabet again...
+            return self.__class__(str(self).join(str(other)), a)
+        if isinstance(other, SeqRecord):
+            raise TypeError("Iterable cannot be a SeqRecord")
         for c in other:
             if isinstance(c, SeqRecord):
                 raise TypeError("Iterable cannot contain SeqRecords")
@@ -1259,7 +1270,7 @@ def join(self, other):
                         )
                     a = Alphabet._consensus_alphabet([a, c.alphabet])
             elif not isinstance(c, str):
-                raise ValueError("Input must be an iterable of Seqs or Strings")
+                raise TypeError("Input must be an iterable of Seqs or Strings")
         temp_data = str(self).join([str(z) for z in other])
         return self.__class__(temp_data, a)
 
@@ -1800,29 +1811,51 @@ def ungap(self, gap="-"):
     def join(self, other):
         """Return a merge of the sequences in other, spaced by the sequence from self.
 
-        Accepts Seq/UnknownSeq objects and Strings as objects to be concatenated with
-        the spacer
+        Accepts either a Seq or string (and iterates over the letters), or an
+        iterable containing Seq or string objects. These arguments will be
+        concatenated with the calling sequence as the spacer:
 
         >>> concatenated = UnknownSeq(5).join([Seq("AAA"), Seq("TTT"), Seq("PPP")])
         >>> concatenated
         Seq('AAA?????TTT?????PPP')
 
-        Throws error if other is not an iterable and if objects inside of the iterable
-        are not Seq/UnknownSeq or String objects.
+        If all the inputs are also UnknownSeq using the same character, then it
+        returns a new UnknownSeq:
 
-        Will only return an UnknownSeq object of all of the objects to be joined are
+        >>> UnknownSeq(5).join([UnknownSeq(3), UnknownSeq(3), UnknownSeq(3)])
+        UnknownSeq(19, character='?')
+
+        Examples taking a single sequence and joining the letters:
+
+        >>> UnknownSeq(3).join("ACGT")
+        Seq('A???C???G???T')
+        >>> UnknownSeq(3).join(UnknownSeq(4))
+        UnknownSeq(13, character='?')
+
+        Will only return an UnknownSeq object if all of the objects to be joined are
         also UnknownSeqs with the same character as the spacer, similar to how the
         addition of an UnknownSeq and another UnknownSeq would work.
         """
-        if not isinstance(
-            other, collections.abc.Iterable
-        ):  # doesn't detect single strings
-            raise ValueError("Input must be an iterable")
-        if isinstance(other, str):
-            raise ValueError("Input must be an iterable")
         from Bio.SeqRecord import SeqRecord  # Lazy to avoid circular imports
 
         a = self.alphabet
+        if isinstance(other, (Seq, MutableSeq)):
+            if a != other.alphabet:
+                if not Alphabet._check_type_compatible([a, other.alphabet]):
+                    raise TypeError(
+                        f"Incompatible alphabets {a!r} and {other.alphabet!r}"
+                    )
+                a = Alphabet._consensus_alphabet([a, other.alphabet])
+            if isinstance(other, UnknownSeq) and self._character == other._character:
+                # Special case, can return an UnknownSeq
+                return self.__class__(
+                    len(other) + len(self) * (len(other) - 1), a, self._character
+                )
+            # No point looping over the seq and checking the alphabet again...
+            return Seq(str(self).join(str(other)), a)
+        if isinstance(other, SeqRecord):
+            raise TypeError("Iterable cannot be a SeqRecord")
+
         type_is_unknown = True
         for c in other:
             if isinstance(c, SeqRecord):
@@ -1839,7 +1872,7 @@ def join(self, other):
             elif isinstance(c, str):
                 type_is_unknown = False
             else:
-                raise ValueError("Input must be an iterable of Seqs or Strings")
+                raise TypeError("Input must be an iterable of Seqs or Strings")
         temp_data = str(self).join([str(z) for z in other])
         if (
             temp_data.count(self._character) == len(temp_data)
@@ -2481,10 +2514,8 @@ def join(self, other):
         Throws error if other is not an iterable and if objects inside of the iterable
         are not Seq or String objects
         """
-        seq_joined = self.toseq().join(
-            other
-        )  # returns Seq object instead of MutableSeq
-        return seq_joined
+        # returns Seq object instead of MutableSeq
+        return self.toseq().join(other)
 
 
 # The transcribe, backward_transcribe, and translate functions are


Test patch:
diff --git a/Tests/test_Seq_objs.py b/Tests/test_Seq_objs.py
--- a/Tests/test_Seq_objs.py
+++ b/Tests/test_Seq_objs.py
@@ -912,40 +912,31 @@ def test_MutableSeq_init_typeerror(self):
         self.assertRaises(TypeError, MutableSeq, 1)
         self.assertRaises(TypeError, MutableSeq, 1.0)
 
-    def test_join_Seq_ValueError(self):
-        """Checks that a ValueError is thrown for all non-iterable types."""
+    def test_join_Seq_TypeError(self):
+        """Checks that a TypeError is thrown for all non-iterable types."""
         # No iterable types which contain non-accepted types either.
 
         spacer = Seq("NNNNN")
-        self.assertRaises(ValueError, spacer.join, 5)
-        self.assertRaises(ValueError, spacer.join, "ATG")
-        self.assertRaises(ValueError, spacer.join, Seq("ATG"))
-        self.assertRaises(ValueError, spacer.join, MutableSeq("ATG"))
-        self.assertRaises(ValueError, spacer.join, ["ATG", "ATG", 5, "ATG"])
-
-    def test_join_UnknownSeq_ValueError(self):
-        """Checks that a ValueError is thrown for all non-iterable types."""
+        self.assertRaises(TypeError, spacer.join, 5)
+        self.assertRaises(TypeError, spacer.join, ["ATG", "ATG", 5, "ATG"])
+
+    def test_join_UnknownSeq_TypeError_iter(self):
+        """Checks that a TypeError is thrown for all non-iterable types."""
         # No iterable types which contain non-accepted types either.
 
         spacer = UnknownSeq(5, character="-")
-        self.assertRaises(ValueError, spacer.join, 5)
-        self.assertRaises(ValueError, spacer.join, "ATG")
-        self.assertRaises(ValueError, spacer.join, Seq("ATG"))
-        self.assertRaises(ValueError, spacer.join, MutableSeq("ATG"))
-        self.assertRaises(ValueError, spacer.join, ["ATG", "ATG", 5, "ATG"])
-
-    def test_join_MutableSeq_ValueError(self):
-        """Checks that a ValueError is thrown for all non-iterable types."""
+        self.assertRaises(TypeError, spacer.join, 5)
+        self.assertRaises(TypeError, spacer.join, ["ATG", "ATG", 5, "ATG"])
+
+    def test_join_MutableSeq_TypeError_iter(self):
+        """Checks that a TypeError is thrown for all non-iterable types."""
         # No iterable types which contain non-accepted types either.
 
         spacer = MutableSeq("MMMMM")
-        self.assertRaises(ValueError, spacer.join, 5)
-        self.assertRaises(ValueError, spacer.join, "ATG")
-        self.assertRaises(ValueError, spacer.join, Seq("ATG"))
-        self.assertRaises(ValueError, spacer.join, MutableSeq("ATG"))
-        self.assertRaises(ValueError, spacer.join, ["ATG", "ATG", 5, "ATG"])
+        self.assertRaises(TypeError, spacer.join, 5)
+        self.assertRaises(TypeError, spacer.join, ["ATG", "ATG", 5, "ATG"])
 
-    def test_join_Seq_TypeError(self):
+    def test_join_Seq_TypeError_alpha(self):
         """Checks that a TypeError is thrown for incompatible alphabets."""
         spacer = Seq("NNNNN", generic_dna)
         self.assertRaises(
@@ -959,7 +950,7 @@ def test_join_Seq_TypeError(self):
             [Seq("NNNNN", generic_protein), Seq("NNNNN", generic_protein)],
         )
 
-    def test_join_UnknownSeq_TypeError(self):
+    def test_join_UnknownSeq_TypeError_alpha(self):
         """Checks that a TypeError is thrown for incompatible alphabets."""
         spacer = UnknownSeq(5, character="-", alphabet=generic_dna)
         self.assertRaises(
@@ -1012,6 +1003,11 @@ def test_join_Seq(self):
             seq_concatenated = spacer.join(example_strings_seqs)
             self.assertEqual(str(seq_concatenated), str(spacer).join(example_strings))
             self.assertEqual(seq_concatenated.alphabet, spacer.alphabet)
+            # Now try single sequence arguments, should join the letters
+            for target in example_strings + example_strings_seqs:
+                self.assertEqual(
+                    str(spacer).join(str(target)), str(spacer.join(target))
+                )
 
     def test_join_Seq_with_file(self):
         """Checks if Seq join correctly concatenates sequence from a file with the spacer."""
@@ -1059,6 +1055,11 @@ def test_join_UnknownSeq(self):
             seq_concatenated = spacer.join(example_strings_seqs)
             self.assertEqual(str(seq_concatenated), str(spacer).join(example_strings))
             self.assertEqual(seq_concatenated.alphabet, spacer.alphabet)
+            # Now try single sequence arguments, should join the letters
+            for target in example_strings + example_strings_seqs:
+                self.assertEqual(
+                    str(spacer).join(str(target)), str(spacer.join(target))
+                )
 
     def test_join_UnknownSeq_with_file(self):
         """Checks if UnknownSeq join correctly concatenates sequence from a file with the spacer."""