Repository: levitsky/pyteomics

Issue:
[proforma] Modifications cannot be pickled after being resolved
Hi @mobiusklein,

We have been using the ProForma module heavily in most of our tools now, so first: A big thanks for all your work and continuous support on it! 

Since Pyteomics 4.7, our tools that use multiprocessing on ProForma peptides have been throwing pickling errors (e.g., compomics/ms2rescore#128). I traced the issue to a recent addition of the Resolver to the modification itself in the `_definition["source"]` attribute. As it contains an sqlalchemy session, it cannot be pickled, and modifications cannot be passed on through multiprocessed functions. 

Pickling of an unresolved modification works:

```python
>>> import pickle
>>> from pyteomics.proforma import process_tag_tokens
>>>
>>> mod = process_tag_tokens("Oxidation")
>>>
>>> with open("test.pickle", "wb") as f:
>>>     pickle.dump(mod, f)
```

Once forcing a resolve by, for instance, getting the mass, it raises a `PicklingError`:

```python
>>> mod.mass
15.994915
>>> 
>>> with open("test.pickle", "wb") as f:
>>>     pickle.dump(mod, f)
---------------------------------------------------------------------------
PicklingError                             Traceback (most recent call last)
Cell In[7], [line 2]
      [1] with open("test.pickle", "wb") as f:
----> [2]     pickle.dump(mod, f)

PicklingError: Can't pickle <class 'sqlalchemy.orm.session.Session'>: it's not the same object as sqlalchemy.orm.session.Session
```

The `source` entry in the `_definition` attribute was added in https://github.com/levitsky/pyteomics/commit/564d79f72cbe816963baec2b987e45d974d473cf. Is it required anywhere? If not, perhaps simply removing the field would fix the issue.

Let me know what you think.

Best,
Ralf

Discussion:

Patch:
diff --git a/pyteomics/proforma.py b/pyteomics/proforma.py
index c24792b..3a17f58 100644
--- a/pyteomics/proforma.py
+++ b/pyteomics/proforma.py
@@ -654,6 +654,19 @@ class ModificationBase(TagBase):
         self._definition = None
         self.style = style
 
+    def __reduce__(self):
+        return self.__class__, (self.value, self.extra, self.group_id, self.style), self.__getstate__()
+
+    def __getstate__(self):
+        if self._definition is None:
+            return None
+        state = self._definition.copy()
+        state['source'] = None
+        return state
+
+    def __setstate__(self, state):
+        self._definition = state
+
     def __eq__(self, other):
         if isinstance(other, ModificationToken):
             return other == self
@@ -984,9 +997,9 @@ class GenericModification(ModificationBase):
         partial(UnimodModification.resolver, strict=False)
     ])
 
-    def __init__(self, value, extra=None, group_id=None):
+    def __init__(self, value, extra=None, group_id=None, style=None):
         super(GenericModification, self).__init__(
-            value, extra, group_id)
+            value, extra, group_id, style)
 
     def _format_main(self):
         return self.value


Test patch:
diff --git a/tests/test_proforma.py b/tests/test_proforma.py
index 6043af8..2425f83 100644
--- a/tests/test_proforma.py
+++ b/tests/test_proforma.py
@@ -1,5 +1,6 @@
 from os import path
 import unittest
+import pickle
 import pyteomics
 pyteomics.__path__ = [path.abspath(
     path.join(path.dirname(__file__), path.pardir, 'pyteomics'))]
@@ -180,5 +181,17 @@ class ModificationHashingTest(unittest.TestCase):
         self.assertIn(mod2, container)
 
 
+class ModificationPicklingTest(unittest.TestCase):
+    def test_pickle(self):
+        mod = GenericModification("UNIMOD:1")
+        payload = pickle.dumps(mod)
+        dup = pickle.loads(payload)
+        self.assertEqual(mod, dup)
+        assert mod.mass is not None
+        payload = pickle.dumps(mod)
+        dup = pickle.loads(payload)
+        self.assertEqual(mod, dup)
+
+
 if __name__ == '__main__':
     unittest.main()