Repository: biopython/biopython

Issue:
Pickling/unpickling `Bio.Align.substitution_matrices.Array` loses the `alphabet` attribute
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

```python
>>> import sys; print(sys.version)
3.8.6 (default, Sep 30 2020, 04:00:38) 
[GCC 10.2.0]
>>> import platform; print(platform.python_implementation()); print(platform.platform())
CPython
Linux-5.9.9-arch1-1-x86_64-with-glibc2.2.5
>>> import Bio; print(Bio.__version__)
1.78
```

### Expected behaviour

Pickling a substitution matrix should also keep the `alphabet` attribute.

### Actual behaviour

It is not the case because `Array` does not implement `__getstate__` and `__setstate__`, so `Array` uses the default `numpy.ndarray` implementation. (issue found by @gamcil in gamcil/clinker#22).

### Steps to reproduce

```python
import pickle
from Bio.Align import substitution_matrices
matrix = substitution_matrices.load("BLOSUM62")
pickled = pickle.dumps(matrix)
loaded = pickle.loads(pickled)
print(loaded.alphabet)
```
This fails with:
```python
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/althonos/.local/lib/python3.8/site-packages/Bio/Align/substitution_matrices/__init__.py", line 245, in alphabet
    return self._alphabet
AttributeError: 'Array' object has no attribute '_alphabet'
```



Discussion:

Resolving patch:
diff --git a/Bio/Align/substitution_matrices/__init__.py b/Bio/Align/substitution_matrices/__init__.py
--- a/Bio/Align/substitution_matrices/__init__.py
+++ b/Bio/Align/substitution_matrices/__init__.py
@@ -233,6 +233,22 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
 
         return results[0] if len(results) == 1 else results
 
+    def __reduce__(self):
+        import pickle
+
+        values = numpy.array(self)
+        state = pickle.dumps(values)
+        alphabet = self._alphabet
+        dims = len(self.shape)
+        dtype = self.dtype
+        arguments = (Array, alphabet, dims, None, dtype)
+        return (Array.__new__, arguments, state)
+
+    def __setstate__(self, state):
+        import pickle
+
+        self[:, :] = pickle.loads(state)
+
     def transpose(self, axes=None):
         """Transpose the array."""
         other = numpy.ndarray.transpose(self, axes)


Test patch:
diff --git a/Tests/test_align_substitution_matrices.py b/Tests/test_align_substitution_matrices.py
--- a/Tests/test_align_substitution_matrices.py
+++ b/Tests/test_align_substitution_matrices.py
@@ -17,12 +17,14 @@
 
 
 import os
+import pickle
 from collections import Counter
 import unittest
 
 
 import numpy
 from Bio import SeqIO
+from Bio.Align import substitution_matrices
 
 from Bio.Data import IUPACData
 
@@ -32,8 +34,6 @@
 
 class Test_basics(unittest.TestCase):
     def test_basics_vector(self):
-        from Bio.Align import substitution_matrices
-
         counts = substitution_matrices.Array("XYZ")
         self.assertEqual(
             str(counts),
@@ -63,8 +63,6 @@ def test_basics_vector(self):
             counts[8]
 
     def test_basics_matrix(self):
-        from Bio.Align import substitution_matrices
-
         counts = substitution_matrices.Array("XYZ", dims=2)
         self.assertEqual(
             str(counts),
@@ -110,8 +108,6 @@ def test_basics_matrix(self):
         )
 
     def test_read_write(self):
-        from Bio.Align import substitution_matrices
-
         path = os.path.join("Align", "hg38.chrom.sizes")
         sizes = substitution_matrices.read(path, numpy.int64)
         # Note that sum(sizes) below is larger than 2147483647, and won't
@@ -132,8 +128,6 @@ def test_read_write(self):
         self.assertEqual(lines[4], "chr5 181538259")
 
     def test_nucleotide_freq(self):
-        from Bio.Align import substitution_matrices
-
         counts = Counter()
         path = os.path.join("Align", "ecoli.fa")
         records = SeqIO.parse(path, "fasta")
@@ -186,8 +180,6 @@ def test_nucleotide_freq(self):
         self.assertAlmostEqual(frequencies["T"], 0.2025723472668810)
 
     def test_protein_freq(self):
-        from Bio.Align import substitution_matrices
-
         counts = Counter()
         path = os.path.join("Align", "cow.fa")
         records = SeqIO.parse(path, "fasta")
@@ -271,6 +263,15 @@ def test_protein_freq(self):
         self.assertAlmostEqual(frequencies["W"], 0.015217055)
         self.assertAlmostEqual(frequencies["Y"], 0.031515526)
 
+    def test_pickling(self):
+        matrix = substitution_matrices.load("BLOSUM62")
+        pickled = pickle.dumps(matrix)
+        loaded = pickle.loads(pickled)
+        self.assertEqual(matrix.alphabet, loaded.alphabet)
+        for c1 in matrix.alphabet:
+            for c2 in matrix.alphabet:
+                self.assertAlmostEqual(matrix[c1, c2], loaded[c1, c2])
+
 
 class TestScoringMatrices(unittest.TestCase):
     @classmethod
@@ -278,7 +279,6 @@ def setUpClass(cls):
 
         from Bio import SeqIO
         from Bio.Align import PairwiseAligner
-        from Bio.Align import substitution_matrices
 
         observed = substitution_matrices.Array(alphabet=protein_alphabet, dims=2)
         aligner = PairwiseAligner()
@@ -2382,7 +2382,6 @@ def test_ident(self):
         # Proceedings of the National Academy of Sciences USA 89(2):
         # 10915-10919 (1992).
         from Bio.Data.IUPACData import protein_letters as alphabet
-        from Bio.Align import substitution_matrices
 
         m = substitution_matrices.load("BLOSUM62")
         self.assertEqual(alphabet, "ACDEFGHIKLMNPQRSTVWY")