Repository: Qiskit/qiskit

Issue:
SparsePauliOp.__getitem__ causes a problematic behavior with numpy’s __mul__
<!-- ⚠️ If you do not respect this template, your issue will be closed -->
<!-- ⚠️ Make sure to browse the opened and closed issues -->

### Information

- **Qiskit Terra version**:master (b5d5a3b)
- **Python version**: 3.8.5
- **Operating system**: macOS

### What is the current behavior?

SparsePauliOp does not support a multiplication with np.int64, np.float64, and...

### Steps to reproduce the problem

For example,
```python
from qiskit.quantum_info import SparsePauliOp, Pauli
import  numpy as  np
print(np.int(2) * SparsePauliOp(Pauli(label='X')))
print('--------')
print(np.int64(2) * SparsePauliOp(Pauli(label='X')))
```
outputs
```
SparsePauliOp([[ True, False]],
              coeffs=[2.+0.j])
--------
[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[SparsePauliOp([[ True, False]],
              coeffs=[2.+0.j])]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]
```


### What is the expected behavior?



### Suggested solutions

There are many solutions.
One is `__getitem__` returns a tuple of Pauli and coeff.
Current SprsePauliOp can get an item again and again. e.g. sp is instance of SparsePauliOp and we can get `sp[0][0][0][0]...`


I found this in https://github.com/Qiskit/qiskit-terra/pull/5261


Discussion:
It looks like numpy ufunc `np.multiply(val, op)` is getting called before the `op.__rmul__(val)`, because somehow it thinks it can treat SparePauliOp as an array. 

I don't really understand how numpy ufuncts interact with the getitem method, but one fix is telling numpy dispatch to use the correct method by adding this to the SparsePauliOp class:

```python
    def __array_ufunc__(self, ufunc, method, *args, **kwargs):
        if (ufunc == np.multiply and method == '__call__') and not kwargs:
            value = args[0] if isinstance(args[1], SparsePauliOp) else args[1]
            return self._multiply(value)
        return NotImplemented
```

Separately it might be good to make getitem return a tuple if the key is an integer rather than a range or slice to prevent the recursion you discovered.

Resolving patch:
diff --git a/qiskit/quantum_info/operators/base_operator.py b/qiskit/quantum_info/operators/base_operator.py
--- a/qiskit/quantum_info/operators/base_operator.py
+++ b/qiskit/quantum_info/operators/base_operator.py
@@ -99,6 +99,9 @@ def __init__(self, input_dims=None, output_dims=None, num_qubits=None):
         elif input_dims is not None and output_dims is not None:
             self._set_dims(input_dims, output_dims)
 
+    # Set higher priority than Numpy array and matrix classes
+    __array_priority__ = 20
+
     def __call__(self, qargs):
         """Return a clone with qargs set"""
         if isinstance(qargs, int):


Test patch:
diff --git a/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py b/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py
--- a/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py
+++ b/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py
@@ -328,7 +328,7 @@ def test_sub_qargs(self, num_qubits):
         value = (spp_op1 - spp_op2(qargs)).to_operator()
         self.assertEqual(value, target)
 
-    @combine(num_qubits=[1, 2, 3], value=[0, 1, 1j, -3+4.4j])
+    @combine(num_qubits=[1, 2, 3], value=[0, 1, 1j, -3+4.4j, np.int64(2)])
     def test_mul(self, num_qubits, value):
         """Test * method for {num_qubits} qubits and value {value}."""
         spp_op = random_unitary(2 ** num_qubits, seed=self.RNG)