Repository: biopython/biopython

Issue:
SeqFeature.WithinPosition instances fail to unpickle
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

3.6.6 (default, Jan 26 2019, 16:53:05) 
[GCC 4.8.5 20150623 (Red Hat 4.8.5-36)]
CPython
Linux-3.10.0-693.21.1.el7.x86_64-x86_64-with-centos-7.2.1511-Core
1.74

### Expected behaviour

Instances of WithinPosition should pickle and unpickle

### Actual behaviour

Unpickling a WithinPosition instance fails with:

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-8-7e6a36b7b135> in <module>
      4 wp = SeqFeature.WithinPosition(10,10,13)
      5 p = pickle.dumps(wp)
----> 6 unpickled_wp = pickle.loads(p)

TypeError: \_\_new\_\_() missing 2 required positional arguments: 'left' and 'right'


### Steps to reproduce

I ran into this unpickling an object containing a SeqRecord derived from UniProt acc P08514, but some digging narrowed it down to:
 
import pickle
from Bio import SeqFeature

wp = SeqFeature.WithinPosition(10,10,13)
p = pickle.dumps(wp)
unpickled_wp = pickle.loads(p)


Pickle expects \_\_getnewargs\_\_() or \_\_getnewargs\_ex\_\_() to be defined for instances of classes that alter the call signature of \_\_new\_\_(cls). I assume WithinPosition inherits \_\_getnewargs\_\_() from int, but then overrides the \_\_new\_\_ signature so the two are not compatible. It looks as if several other *Position classes in this module will fail in the same way


Discussion:
Do you know enough about pickle to fix this and add a couple of unit tests, e.g. to ``Tests/test_SeqFeature.py`` for the positions, and perhaps also higher level in ``Tests/test_SeqRecord.py``?

You can see a test added here to help with pickling a ``SeqRecord`` in the past:

https://github.com/biopython/biopython/blob/biopython-174/Bio/SeqRecord.py#L50
I'm not certain, but the attached diff seems to work.

Strictly speaking ExactPosition, BeforePosition and AfterPosition should also define \_\_getnewargs\_\_ returning the \_\_new\_\_ arguments position and extension. I haven't done so here because:
1) extension is only available to the instance by way of a method marked obsolete
2) in practice it doesn't matter because extension is defined by default and any other value raises an error

[2207diff.txt](https://github.com/biopython/biopython/files/3495170/2207diff.txt)

Other than a repeated typo ("intances"), that looks good. Could you submit it as a pull request please?

Resolving patch:
diff --git a/Bio/SeqFeature.py b/Bio/SeqFeature.py
--- a/Bio/SeqFeature.py
+++ b/Bio/SeqFeature.py
@@ -1707,6 +1707,13 @@ def __new__(cls, position, left, right):
         obj._right = right
         return obj
 
+    def __getnewargs__(self):
+        """Return the arguments accepted by __new__.
+
+        Necessary to allow pickling and unpickling of class instances.
+        """
+        return (int(self), self._left, self._right)
+
     def __repr__(self):
         """Represent the WithinPosition object as a string for debugging."""
         return "%s(%i, left=%i, right=%i)" \
@@ -1804,6 +1811,7 @@ class BetweenPosition(int, AbstractPosition):
 
     i.e. For equality (and sorting) the position objects behave like
     integers.
+
     """
 
     def __new__(cls, position, left, right):
@@ -1814,6 +1822,13 @@ def __new__(cls, position, left, right):
         obj._right = right
         return obj
 
+    def __getnewargs__(self):
+        """Return the arguments accepted by __new__.
+
+        Necessary to allow pickling and unpickling of class instances.
+        """
+        return (int(self), self._left, self._right)
+
     def __repr__(self):
         """Represent the BetweenPosition object as a string for debugging."""
         return "%s(%i, left=%i, right=%i)" \
@@ -2056,6 +2071,13 @@ def __new__(cls, position, choices):
         obj.position_choices = choices
         return obj
 
+    def __getnewargs__(self):
+        """Return the arguments accepted by __new__.
+
+        Necessary to allow pickling and unpickling of class instances.
+        """
+        return (int(self), self.position_choices)
+
     @property
     def position(self):
         """Legacy attribute to get (left) position as integer (OBSOLETE)."""


Test patch:
diff --git a/Tests/test_SeqFeature.py b/Tests/test_SeqFeature.py
--- a/Tests/test_SeqFeature.py
+++ b/Tests/test_SeqFeature.py
@@ -16,6 +16,7 @@
 from Bio.SeqFeature import FeatureLocation, AfterPosition, BeforePosition
 from Bio.SeqFeature import CompoundLocation, UnknownPosition, SeqFeature
 from Bio.SeqFeature import ExactPosition, WithinPosition, BetweenPosition
+from Bio.SeqFeature import OneOfPosition
 
 
 class TestReference(unittest.TestCase):
@@ -194,6 +195,34 @@ def test_fuzzy(self):
         self.assertEqual(location3.nofuzzy_end, 40)
 
 
+class TestPositions(unittest.TestCase):
+
+    def test_pickle(self):
+        """Test pickle behaviour of position instances."""
+        # setup
+        import pickle
+        within_pos = WithinPosition(10, left=10, right=13)
+        between_pos = BetweenPosition(24, left=20, right=24)
+        oneof_pos = OneOfPosition(1888, [ExactPosition(1888), ExactPosition(1901)])
+        # test __getnewargs__
+        self.assertEqual(within_pos.__getnewargs__(), (10, 10, 13))
+        self.assertEqual(between_pos.__getnewargs__(), (24, 20, 24))
+        self.assertEqual(oneof_pos.__getnewargs__(),
+                         (1888, [ExactPosition(1888), ExactPosition(1901)]))
+        # test pickle behaviour
+        within_pos2 = pickle.loads(pickle.dumps(within_pos))
+        between_pos2 = pickle.loads(pickle.dumps(between_pos))
+        oneof_pos2 = pickle.loads(pickle.dumps(oneof_pos))
+        self.assertEqual(within_pos, within_pos2)
+        self.assertEqual(between_pos, between_pos2)
+        self.assertEqual(oneof_pos, oneof_pos2)
+        self.assertEqual(within_pos._left, within_pos2._left)
+        self.assertEqual(within_pos._right, within_pos2._right)
+        self.assertEqual(between_pos._left, between_pos2._left)
+        self.assertEqual(between_pos._right, between_pos2._right)
+        self.assertEqual(oneof_pos.position_choices, oneof_pos2.position_choices)
+
+
 if __name__ == "__main__":
     runner = unittest.TextTestRunner(verbosity=2)
     unittest.main(testRunner=runner)