Repository: scipy/scipy

Issue:
BUG: sparse.dok_matrix throws KeyError for valid pop(key) since v-1.12.0
### Describe your issue.

sparse.dok_matrix throws KeyError for valid `pop(key)` since v-1.12.0. Worked well until 1.11.4.

### Reproducing Code Example

```python
import scipy.sparse
S = scipy.sparse.dok_matrix((2,2))
S[0, 1] = 1
print('keys', list(S.keys()))
S.pop((0, 1))
```


### Error message

```shell
Traceback (most recent call last):
  File "tmp.py", line 5, in <module>
    S.pop((0, 1))
KeyError: (0, 1)
```


### SciPy/NumPy/Python version and system information

```shell
1.12.0 1.26.4 sys.version_info(major=3, minor=11, micro=7, releaselevel='final', serial=0)
{
  "Compilers": {
    "c": {
      "name": "gcc",
      "linker": "ld.bfd",
      "version": "10.2.1",
      "commands": "cc"
    },
    "cython": {
      "name": "cython",
      "linker": "cython",
      "version": "3.0.8",
      "commands": "cython"
    },
    "c++": {
      "name": "gcc",
      "linker": "ld.bfd",
      "version": "10.2.1",
      "commands": "c++"
    },
    "fortran": {
      "name": "gcc",
      "linker": "ld.bfd",
      "version": "10.2.1",
      "commands": "gfortran"
    },
    "pythran": {
      "version": "0.15.0",
      "include directory": "../../tmp/pip-build-env-wkrrblds/overlay/lib/python3.11/site-packages/pythran"
    }
  },
  "Machine Information": {
    "host": {
      "cpu": "x86_64",
      "family": "x86_64",
      "endian": "little",
      "system": "linux"
    },
    "build": {
      "cpu": "x86_64",
      "family": "x86_64",
      "endian": "little",
      "system": "linux"
    },
    "cross-compiled": false
  },
  "Build Dependencies": {
    "blas": {
      "name": "openblas",
      "found": true,
      "version": "0.3.21.dev",
      "detection method": "pkgconfig",
      "include directory": "/usr/local/include",
      "lib directory": "/usr/local/lib",
      "openblas configuration": "USE_64BITINT= DYNAMIC_ARCH=1 DYNAMIC_OLDER= NO_CBLAS= NO_LAPACK= NO_LAPACKE= NO_AFFINITY=1 USE_OPENMP= HASWELL MAX_THREADS=2",
      "pc file directory": "/usr/local/lib/pkgconfig"
    },
    "lapack": {
      "name": "openblas",
      "found": true,
      "version": "0.3.21.dev",
      "detection method": "pkgconfig",
      "include directory": "/usr/local/include",
      "lib directory": "/usr/local/lib",
      "openblas configuration": "USE_64BITINT= DYNAMIC_ARCH=1 DYNAMIC_OLDER= NO_CBLAS= NO_LAPACK= NO_LAPACKE= NO_AFFINITY=1 USE_OPENMP= HASWELL MAX_THREADS=2",
      "pc file directory": "/usr/local/lib/pkgconfig"
    },
    "pybind11": {
      "name": "pybind11",
      "version": "2.11.1",
      "detection method": "config-tool",
      "include directory": "unknown"
    }
  },
  "Python Information": {
    "path": "/opt/python/cp311-cp311/bin/python",
    "version": "3.11"
  }
}
```



Discussion:

Resolving patch:
diff --git a/scipy/sparse/_dok.py b/scipy/sparse/_dok.py
--- a/scipy/sparse/_dok.py
+++ b/scipy/sparse/_dok.py
@@ -89,6 +89,24 @@ def __delitem__(self, key, /):
     def clear(self):
         return self._dict.clear()
 
+    def pop(self, key, default=None, /):
+        return self._dict.pop(key, default)
+
+    def __reversed__(self):
+        raise TypeError("reversed is not defined for dok_array type")
+
+    def __or__(self, other):
+        type_names = f"{type(self).__name__} and {type(other).__name__}"
+        raise TypeError(f"unsupported operand type for |: {type_names}")
+
+    def __ror__(self, other):
+        type_names = f"{type(self).__name__} and {type(other).__name__}"
+        raise TypeError(f"unsupported operand type for |: {type_names}")
+
+    def __ior__(self, other):
+        type_names = f"{type(self).__name__} and {type(other).__name__}"
+        raise TypeError(f"unsupported operand type for |: {type_names}")
+
     def popitem(self):
         return self._dict.popitem()
 
@@ -414,11 +432,10 @@ def copy(self):
     @classmethod
     def fromkeys(cls, iterable, value=1, /):
         tmp = dict.fromkeys(iterable, value)
-        keys = tmp.keys()
-        if isinstance(next(iter(keys)), tuple):
-            shape = tuple(max(idx) + 1 for idx in zip(*keys))
+        if isinstance(next(iter(tmp)), tuple):
+            shape = tuple(max(idx) + 1 for idx in zip(*tmp))
         else:
-            shape = (max(keys) + 1,)
+            shape = (max(tmp) + 1,)
         result = cls(shape, dtype=type(value))
         result._dict = tmp
         return result
@@ -633,3 +650,23 @@ def get_shape(self):
         return self._shape
 
     shape = property(fget=get_shape, fset=set_shape)
+
+    def __reversed__(self):
+        return self._dict.__reversed__()
+
+    def __or__(self, other):
+        if isinstance(other, _dok_base):
+            return self._dict | other._dict
+        return self._dict | other
+
+    def __ror__(self, other):
+        if isinstance(other, _dok_base):
+            return self._dict | other._dict
+        return self._dict | other
+
+    def __ior__(self, other):
+        if isinstance(other, _dok_base):
+            self._dict |= other._dict
+        else:
+            self._dict |= other
+        return self


Test patch:
diff --git a/scipy/sparse/tests/test_dok.py b/scipy/sparse/tests/test_dok.py
--- a/scipy/sparse/tests/test_dok.py
+++ b/scipy/sparse/tests/test_dok.py
@@ -1,29 +1,203 @@
-from numpy.testing import assert_array_equal
-from scipy.sparse import dok_array
+import pytest
+import numpy as np
+from numpy.testing import assert_equal
+import scipy as sp
+from scipy.sparse import dok_array, dok_matrix
 
 
+@pytest.fixture
+def d():
+    return {(0, 1): 1, (0, 2): 2}
+
+@pytest.fixture
+def A():
+    return np.array([[0, 1, 2], [0, 0, 0], [0, 0, 0]])
+
+@pytest.fixture(params=[dok_array, dok_matrix])
+def Asp(request):
+    A = request.param((3, 3))
+    A[(0, 1)] = 1
+    A[(0, 2)] = 2
+    yield A
+
+# Note: __iter__ and comparison dunders act like ndarrays for DOK, not dict.
+# Dunders reversed, or, ror, ior work as dict for dok_matrix, raise for dok_array
+# All other dict methods on DOK format act like dict methods (with extra checks).
+
+# Start of tests
+################
+def test_dict_methods_covered(d, Asp):
+    d_methods = set(dir(d)) - {"__class_getitem__"}
+    asp_methods = set(dir(Asp))
+    assert d_methods < asp_methods
+
+def test_clear(d, Asp):
+    assert d.items() == Asp.items()
+    d.clear()
+    Asp.clear()
+    assert d.items() == Asp.items()
+
+def test_copy(d, Asp):
+    assert d.items() == Asp.items()
+    dd = d.copy()
+    asp = Asp.copy()
+    assert dd.items() == asp.items()
+    assert asp.items() == Asp.items()
+    asp[(0, 1)] = 3
+    assert Asp[(0, 1)] == 1
+
 def test_fromkeys_default():
     # test with default value
     edges = [(0, 2), (1, 0), (2, 1)]
     Xdok = dok_array.fromkeys(edges)
-    assert_array_equal(
-        Xdok.toarray(),
-        [[0, 0, 1], [1, 0, 0], [0, 1, 0]],
-    )
+    X = [[0, 0, 1], [1, 0, 0], [0, 1, 0]]
+    assert_equal(Xdok.toarray(), X)
 
 def test_fromkeys_positional():
     # test with positional value
     edges = [(0, 2), (1, 0), (2, 1)]
     Xdok = dok_array.fromkeys(edges, -1)
-    assert_array_equal(
-        Xdok.toarray(),
-        [[0, 0, -1], [-1, 0, 0], [0, -1, 0]],
-    )
+    X = [[0, 0, -1], [-1, 0, 0], [0, -1, 0]]
+    assert_equal(Xdok.toarray(), X)
 
 def test_fromkeys_iterator():
     it = ((a, a % 2) for a in range(4))
     Xdok = dok_array.fromkeys(it)
-    assert_array_equal(
-        Xdok.toarray(),
-        [[1, 0], [0, 1], [1, 0], [0, 1]],
-    )
+    X = [[1, 0], [0, 1], [1, 0], [0, 1]]
+    assert_equal(Xdok.toarray(), X)
+
+def test_get(d, Asp):
+    assert Asp.get((0, 1)) == d.get((0, 1))
+    assert Asp.get((0, 0), 99) == d.get((0, 0), 99)
+    with pytest.raises(IndexError, match="out of bounds"):
+        Asp.get((0, 4), 99)
+
+def test_items(d, Asp):
+    assert Asp.items() == d.items()
+
+def test_keys(d, Asp):
+    assert Asp.keys() == d.keys()
+
+def test_pop(d, Asp):
+    assert d.pop((0, 1)) == 1
+    assert Asp.pop((0, 1)) == 1
+    assert d.items() == Asp.items()
+
+def test_popitem(d, Asp):
+    assert d.popitem() == Asp.popitem()
+    assert d.items() == Asp.items()
+
+def test_setdefault(d, Asp):
+    assert Asp.setdefault((0, 1), 4) == 1
+    assert Asp.setdefault((2, 2), 4) == 4
+    d.setdefault((0, 1), 4)
+    d.setdefault((2, 2), 4)
+    assert d.items() == Asp.items()
+
+def test_update(d, Asp):
+    with pytest.raises(NotImplementedError):
+        Asp.update(Asp)
+
+def test_values(d, Asp):
+    # Note: dict.values are strange: d={1: 1}; d.values() == d.values() is False
+    # Using list(d.values()) makes them comparable.
+    assert list(Asp.values()) == list(d.values())
+
+def test_dunder_getitem(d, Asp):
+    assert Asp[(0, 1)] == d[(0, 1)]
+
+def test_dunder_setitem(d, Asp):
+    Asp[(1, 1)] = 5
+    d[(1, 1)] = 5
+    assert d.items() == Asp.items()
+
+def test_dunder_delitem(d, Asp):
+    del Asp[(0, 1)]
+    del d[(0, 1)]
+    assert d.items() == Asp.items()
+
+def test_dunder_contains(d, Asp):
+    assert ((0, 1) in d) == ((0, 1) in Asp)
+    assert ((0, 0) in d) == ((0, 0) in Asp)
+
+def test_dunder_len(d, Asp):
+    assert len(d) == len(Asp)
+
+# Note: dunders reversed, or, ror, ior work as dict for dok_matrix, raise for dok_array
+def test_dunder_reversed(d, Asp):
+    if isinstance(Asp, dok_array):
+        with pytest.raises(TypeError):
+            list(reversed(Asp))
+    else:
+        list(reversed(Asp)) == list(reversed(d))
+
+def test_dunder_ior(d, Asp):
+    if isinstance(Asp, dok_array):
+        with pytest.raises(TypeError):
+            Asp |= Asp
+    else:
+        dd = {(0, 0): 5}
+        Asp |= dd
+        assert Asp[(0, 0)] == 5
+        d |= dd
+        assert d.items() == Asp.items()
+        dd |= Asp
+        assert dd.items() == Asp.items()
+
+def test_dunder_or(d, Asp):
+    if isinstance(Asp, dok_array):
+        with pytest.raises(TypeError):
+            Asp | Asp
+    else:
+        assert d | d == Asp | d
+        assert d | d == Asp | Asp
+
+def test_dunder_ror(d, Asp):
+    if isinstance(Asp, dok_array):
+        with pytest.raises(TypeError):
+            Asp | Asp
+        with pytest.raises(TypeError):
+            d | Asp
+    else:
+        assert Asp.__ror__(d) == Asp.__ror__(Asp)
+        assert d.__ror__(d) == Asp.__ror__(d)
+        assert d | Asp
+
+# Note: comparison dunders, e.g. ==, >=, etc follow np.array not dict
+def test_dunder_eq(A, Asp):
+    with np.testing.suppress_warnings() as sup:
+        sup.filter(sp.sparse.SparseEfficiencyWarning)
+        assert (Asp == Asp).toarray().all()
+        assert (A == Asp).all()
+
+def test_dunder_ne(A, Asp):
+    assert not (Asp != Asp).toarray().any()
+    assert not (A != Asp).any()
+
+def test_dunder_lt(A, Asp):
+    assert not (Asp < Asp).toarray().any()
+    assert not (A < Asp).any()
+
+def test_dunder_gt(A, Asp):
+    assert not (Asp > Asp).toarray().any()
+    assert not (A > Asp).any()
+
+def test_dunder_le(A, Asp):
+    with np.testing.suppress_warnings() as sup:
+        sup.filter(sp.sparse.SparseEfficiencyWarning)
+        assert (Asp <= Asp).toarray().all()
+        assert (A <= Asp).all()
+
+def test_dunder_ge(A, Asp):
+    with np.testing.suppress_warnings() as sup:
+        sup.filter(sp.sparse.SparseEfficiencyWarning)
+        assert (Asp >= Asp).toarray().all()
+        assert (A >= Asp).all()
+
+# Note: iter dunder follows np.array not dict
+def test_dunder_iter(A, Asp):
+    if isinstance(Asp, dok_array):
+        with pytest.raises(NotImplementedError):
+            [a.toarray() for a in Asp]
+    else:
+        assert all((a == asp).all() for a, asp in zip(A, Asp))