Repository: biopython/biopython

Issue:
PDBIo error when saving chain with more than one character
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

```python
import sys; print(sys.version)
import platform; print(platform.python_implementation()); print(platform.platform())
import Bio; print(Bio.__version__)

3.8.6 | packaged by conda-forge | (default, Nov 27 2020, 19:31:52) 
[GCC 9.3.0]
CPython
Linux-5.8.0-31-generic-x86_64-with-glibc2.10
1.78
```


### Expected behaviour

Using `PDBio.save` to save any chain that has more than one character as identifier should work, regardless on the length of the chain id.

### Actual behaviour

When subclassing `Select` for chain extraction, and using `PDBio` for saving, an exception is raised as follows.
```python
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "PDBIO.py", line 370, in save
    s = get_atom_line(
  File "PDBIO.py", line 229, in _get_atom_line
    return _ATOM_FORMAT_STRING % args
TypeError: %c requires int or char

```

### Steps to reproduce

Minimal code to reproduce:

```python
from Bio.PDB import PDBList, PDBIO, FastMMCIFParser, Select

class ChainSelect(Select):
    def __init__(self, chain):
        self.chain = chain

    def accept_chain(self, chain):
        if chain.get_id() == self.chain:
            return 1
        else:
            return 0

pdbl = PDBList()
io = PDBIO()
parser = FastMMCIFParser(QUIET=True)

pdbl.retrieve_pdb_file('6kmw', pdir='.', file_format='mmCif')
structure = parser.get_structure('6kmw', '6kmw.cif')
io.set_structure(structure)
io.save('6kmw_aA.pdb', ChainSelect('aA'))
```



Discussion:
Reminds me of the discussion on #3250 and #3262 - can you look at this @JoaoRodrigues ?
Thanks for the ping @peterjc .

I disagree that this use case "should work, regardless of the length of the chain id". The PDB format allows for _one_ character in that field, so what would you do then? Truncate and issue a warning? I prefer to explicitly throw an exception (probably a more informative one) and let the user change the chain id themselves before extracting. 
You are absolutely right, I forgot that when using `PDBIO` the output file must comply to the `PDB` file format. 
It would still be nice to have a more informative exception though.
Thank you very much for the clarification.
More informative exception it is then :) I'll push it through this afternoon.

Resolving patch:
diff --git a/Bio/PDB/PDBExceptions.py b/Bio/PDB/PDBExceptions.py
--- a/Bio/PDB/PDBExceptions.py
+++ b/Bio/PDB/PDBExceptions.py
@@ -29,3 +29,10 @@ class PDBConstructionWarning(BiopythonWarning):
     """Define class PDBConstructionWarning."""
 
     pass
+
+
+# The SMCRA structure could not be written to file
+class PDBIOException(Exception):
+    """Define class PDBIOException."""
+
+    pass
diff --git a/Bio/PDB/PDBIO.py b/Bio/PDB/PDBIO.py
--- a/Bio/PDB/PDBIO.py
+++ b/Bio/PDB/PDBIO.py
@@ -6,6 +6,12 @@
 """Output of PDB files."""
 
 
+import warnings
+
+# Exceptions and Warnings
+from Bio import BiopythonWarning
+from Bio.PDB.PDBExceptions import PDBIOException
+
 # To allow saving of chains, residues, etc..
 from Bio.PDB.StructureBuilder import StructureBuilder
 
@@ -20,6 +26,10 @@
     "%s%5i %-4s%c%3s %c%4i%c   %8.3f%8.3f%8.3f %7s  %6s      %2s\n"
 )
 
+_TER_FORMAT_STRING = (
+    "TER   %5i      %3s %c%4i%c                                                      \n"
+)
+
 
 class Select:
     """Select everything for PDB output (for use as a base class).
@@ -159,53 +169,62 @@ def _get_atom_line(
         else:
             record_type = "ATOM  "
 
+        # Ensure chain id isn't longer than 1 character
+        if len(chain_id) > 1:
+            raise ValueError(f"Chain ID must be of length 1: {chain_id!r} > 1")
+
+        # Atom properties
+
+        # Check if the atom serial number is an integer
+        # Not always the case for structures built from
+        # mmCIF files.
+        try:
+            atom_number = int(atom_number)
+        except ValueError:
+            raise ValueError(
+                f"{atom_number!r} is not a number."
+                "Atom serial numbers must be numerical"
+                " If you are converting from an mmCIF"
+                " structure, try using"
+                " preserve_atom_numbering=False"
+            )
+
+        # Check if the element is valid, unknown (X), or blank
         if atom.element:
             element = atom.element.strip().upper()
             if element.capitalize() not in atom_weights and element != "X":
-                raise ValueError("Unrecognised element %r" % atom.element)
+                raise ValueError(f"Unrecognised element {atom.element}")
             element = element.rjust(2)
         else:
             element = "  "
 
-        name = atom.get_fullname().strip()
-        # Pad atom name if:
+        # Format atom name
+        # Pad if:
         #     - smaller than 4 characters
         # AND - is not C, N, O, S, H, F, P, ..., one letter elements
         # AND - first character is NOT numeric (funky hydrogen naming rules)
+        name = atom.fullname.strip()
         if len(name) < 4 and name[:1].isalpha() and len(element.strip()) < 2:
             name = " " + name
 
-        altloc = atom.get_altloc()
-        x, y, z = atom.get_coord()
-
-        # PDB Arguments
-        if not self.is_pqr:
-            bfactor = atom.get_bfactor()
-            occupancy = atom.get_occupancy()
-
-        # PQR Arguments
-        else:
-            radius = atom.get_radius()
-            pqr_charge = atom.get_charge()
+        altloc = atom.altloc
+        x, y, z = atom.coord
 
+        # Write PDB format line
         if not self.is_pqr:
+            bfactor = atom.bfactor
             try:
-                occupancy_str = "%6.2f" % occupancy
-            except TypeError:
-                if occupancy is None:
-                    occupancy_str = " " * 6
-                    import warnings
-                    from Bio import BiopythonWarning
-
+                occupancy = f"{atom.occupancy:6.2f}"
+            except (TypeError, ValueError):
+                if atom.occupancy is None:
+                    occupancy = " " * 6
                     warnings.warn(
-                        "Missing occupancy in atom %r written as blank"
-                        % (atom.get_full_id(),),
+                        f"Missing occupancy in atom {atom.full_id!r} written as blank",
                         BiopythonWarning,
                     )
                 else:
-                    raise TypeError(
-                        "Invalid occupancy %r in atom %r"
-                        % (occupancy, atom.get_full_id())
+                    raise ValueError(
+                        f"Invalid occupancy value: {atom.occupancy!r}"
                     ) from None
 
             args = (
@@ -220,7 +239,7 @@ def _get_atom_line(
                 x,
                 y,
                 z,
-                occupancy_str,
+                occupancy,
                 bfactor,
                 segid,
                 element,
@@ -228,43 +247,33 @@ def _get_atom_line(
             )
             return _ATOM_FORMAT_STRING % args
 
+        # Write PQR format line
         else:
-            # PQR case
             try:
-                pqr_charge = "%7.4f" % pqr_charge
-            except TypeError:
-                if pqr_charge is None:
+                pqr_charge = f"{atom.pqr_charge:7.4f}"
+            except (TypeError, ValueError):
+                if atom.pqr_charge is None:
                     pqr_charge = " " * 7
-                    import warnings
-                    from Bio import BiopythonWarning
-
                     warnings.warn(
-                        "Missing charge in atom %r written as blank"
-                        % (atom.get_full_id(),),
+                        f"Missing PQR charge in atom {atom.full_id} written as blank",
                         BiopythonWarning,
                     )
                 else:
-                    raise TypeError(
-                        "Invalid charge %r in atom %r"
-                        % (pqr_charge, atom.get_full_id())
+                    raise ValueError(
+                        f"Invalid PQR charge value: {atom.pqr_charge!r}"
                     ) from None
+
             try:
-                radius = "%6.4f" % radius
-            except TypeError:
-                if radius is None:
+                radius = f"{atom.radius:6.4f}"
+            except (TypeError, ValueError):
+                if atom.radius is None:
                     radius = " " * 6
-                    import warnings
-                    from Bio import BiopythonWarning
-
                     warnings.warn(
-                        "Missing radius in atom %r written as blank"
-                        % (atom.get_full_id(),),
+                        f"Missing radius in atom {atom.full_id} written as blank",
                         BiopythonWarning,
                     )
                 else:
-                    raise TypeError(
-                        "Invalid radius %r in atom %r" % (radius, atom.get_full_id())
-                    ) from None
+                    raise ValueError(f"Invalid radius value: {atom.radius}") from None
 
             args = (
                 record_type,
@@ -309,86 +318,84 @@ def save(self, file, select=_select, write_end=True, preserve_atom_numbering=Fal
 
         Typically select is a subclass of L{Select}.
         """
-        get_atom_line = self._get_atom_line
         if isinstance(file, str):
-            fp = open(file, "w")
-            close_file = 1
+            fhandle = open(file, "w")
         else:
             # filehandle, I hope :-)
-            fp = file
-            close_file = 0
-        # multiple models?
-        if len(self.structure) > 1 or self.use_model_flag:
-            model_flag = 1
-        else:
-            model_flag = 0
-        for model in self.structure.get_list():
-            if not select.accept_model(model):
-                continue
-            # necessary for ENDMDL
-            # do not write ENDMDL if no residues were written
-            # for this model
-            model_residues_written = 0
-            if not preserve_atom_numbering:
-                atom_number = 1
-            if model_flag:
-                fp.write("MODEL      %s\n" % model.serial_num)
-            for chain in model.get_list():
-                if not select.accept_chain(chain):
+            fhandle = file
+
+        with fhandle:
+            get_atom_line = self._get_atom_line
+
+            # multiple models?
+            if len(self.structure) > 1 or self.use_model_flag:
+                model_flag = 1
+            else:
+                model_flag = 0
+
+            for model in self.structure.get_list():
+                if not select.accept_model(model):
                     continue
-                chain_id = chain.get_id()
-                # necessary for TER
-                # do not write TER if no residues were written
-                # for this chain
-                chain_residues_written = 0
-                for residue in chain.get_unpacked_list():
-                    if not select.accept_residue(residue):
+                # necessary for ENDMDL
+                # do not write ENDMDL if no residues were written
+                # for this model
+                model_residues_written = 0
+                if not preserve_atom_numbering:
+                    atom_number = 1
+                if model_flag:
+                    fhandle.write("MODEL      %s\n" % model.serial_num)
+
+                for chain in model.get_list():
+                    if not select.accept_chain(chain):
                         continue
-                    hetfield, resseq, icode = residue.get_id()
-                    resname = residue.get_resname()
-                    segid = residue.get_segid()
-                    for atom in residue.get_unpacked_list():
-                        if select.accept_atom(atom):
-                            chain_residues_written = 1
-                            model_residues_written = 1
-                            if preserve_atom_numbering:
-                                atom_number = atom.get_serial_number()
-
-                                # Check if the atom serial number is an integer
-                                # Not always the case for mmCIF files.
+                    chain_id = chain.id
+
+                    # necessary for TER
+                    # do not write TER if no residues were written
+                    # for this chain
+                    chain_residues_written = 0
+
+                    for residue in chain.get_unpacked_list():
+                        if not select.accept_residue(residue):
+                            continue
+                        hetfield, resseq, icode = residue.id
+                        resname = residue.resname
+                        segid = residue.segid
+                        for atom in residue.get_unpacked_list():
+                            if select.accept_atom(atom):
+                                chain_residues_written = 1
+                                model_residues_written = 1
+                                if preserve_atom_numbering:
+                                    atom_number = atom.serial_number
+
                                 try:
-                                    atom_number = int(atom_number)
-                                except ValueError:
-                                    raise ValueError(
-                                        f"{repr(atom_number)} is not a number."
-                                        "Atom serial numbers must be numerical"
-                                        " If you are converting from an mmCIF"
-                                        " structure, try using"
-                                        " preserve_atom_numbering=False"
+                                    s = get_atom_line(
+                                        atom,
+                                        hetfield,
+                                        segid,
+                                        atom_number,
+                                        resname,
+                                        resseq,
+                                        icode,
+                                        chain_id,
                                     )
-
-                            s = get_atom_line(
-                                atom,
-                                hetfield,
-                                segid,
-                                atom_number,
-                                resname,
-                                resseq,
-                                icode,
-                                chain_id,
-                            )
-                            fp.write(s)
-                            if not preserve_atom_numbering:
-                                atom_number += 1
-                if chain_residues_written:
-                    fp.write(
-                        "TER   %5i      %3s %c%4i%c                                                      \n"
-                        % (atom_number, resname, chain_id, resseq, icode)
-                    )
-
-            if model_flag and model_residues_written:
-                fp.write("ENDMDL\n")
-        if write_end:
-            fp.write("END   \n")
-        if close_file:
-            fp.close()
+                                except Exception as err:
+                                    # catch and re-raise with more information
+                                    raise PDBIOException(
+                                        f"Error when writing atom {atom.full_id}"
+                                    ) from err
+                                else:
+                                    fhandle.write(s)
+                                    # inconsequential if preserve_atom_numbering is True
+                                    atom_number += 1
+
+                    if chain_residues_written:
+                        fhandle.write(
+                            _TER_FORMAT_STRING
+                            % (atom_number, resname, chain_id, resseq, icode)
+                        )
+
+                if model_flag and model_residues_written:
+                    fhandle.write("ENDMDL\n")
+            if write_end:
+                fhandle.write("END   \n")


Test patch:
diff --git a/Tests/test_PDB_MMCIFParser.py b/Tests/test_PDB_MMCIFParser.py
--- a/Tests/test_PDB_MMCIFParser.py
+++ b/Tests/test_PDB_MMCIFParser.py
@@ -29,7 +29,11 @@
 
 
 from Bio.Seq import Seq
-from Bio.PDB.PDBExceptions import PDBConstructionException, PDBConstructionWarning
+from Bio.PDB.PDBExceptions import (
+    PDBConstructionException,
+    PDBConstructionWarning,
+    PDBIOException,
+)
 
 from Bio.PDB import PPBuilder, CaPPBuilder
 from Bio.PDB.MMCIFParser import MMCIFParser, FastMMCIFParser
@@ -383,7 +387,7 @@ def test_conversion_preserve_numbering(self):
         pdb_writer.set_structure(cif_struct)
         filenumber, filename = tempfile.mkstemp()
 
-        with self.assertRaises(ValueError):
+        with self.assertRaises(PDBIOException):
             pdb_writer.save(filename, preserve_atom_numbering=True)
 
 
diff --git a/Tests/test_PDB_PDBIO.py b/Tests/test_PDB_PDBIO.py
--- a/Tests/test_PDB_PDBIO.py
+++ b/Tests/test_PDB_PDBIO.py
@@ -21,7 +21,11 @@
 from Bio import BiopythonWarning
 from Bio.PDB import PDBParser, PDBIO, Select
 from Bio.PDB import Atom, Residue
-from Bio.PDB.PDBExceptions import PDBConstructionException, PDBConstructionWarning
+from Bio.PDB.PDBExceptions import (
+    PDBConstructionException,
+    PDBConstructionWarning,
+    PDBIOException,
+)
 
 
 class WriteTest(unittest.TestCase):
@@ -321,7 +325,7 @@ def test_pdbio_write_x_element(self):
             os.remove(filename)
 
     def test_pdbio_write_unk_element(self):
-        """PDBIO raises ValueError when writing unrecognised atomic elements."""
+        """PDBIO raises PDBIOException when writing unrecognised atomic elements."""
         struct1 = self.structure
 
         atom = next(struct1.get_atoms())
@@ -332,7 +336,7 @@ def test_pdbio_write_unk_element(self):
         filenumber, filename = tempfile.mkstemp()
         os.close(filenumber)
 
-        with self.assertRaises(ValueError):
+        with self.assertRaises(PDBIOException):
             self.io.save(filename)
         os.remove(filename)
 
diff --git a/Tests/test_PDB_internal_coords.py b/Tests/test_PDB_internal_coords.py
--- a/Tests/test_PDB_internal_coords.py
+++ b/Tests/test_PDB_internal_coords.py
@@ -9,6 +9,7 @@
 
 import unittest
 import re
+import tempfile
 import warnings
 
 try:
@@ -117,10 +118,10 @@ def test_model_change_internal_coords(self):
                     ric.set_angle("psi", nv)
                     nvpsi[str(r)] = nv
         mdl.internal_to_atom_coordinates()
-        sf = StringIO()
-        write_PDB(self.pdb_1LCD, sf)
-        sf.seek(0)
-        new_1LCD = self.PDB_parser.get_structure("1LCD", sf)
+
+        filenumber, filename = tempfile.mkstemp()
+        write_PDB(self.pdb_1LCD, filename)
+        new_1LCD = self.PDB_parser.get_structure("1LCD", filename)
         for mdl in new_1LCD:
             if mdl.serial_num == 2:
                 break