Repository: PennyLaneAI/pennylane

Issue:
[BUG] Doc CI checks fail with `AttributeError: module 'mistune' has no attribute 'BlockGrammar'`
### Expected behavior

The documentation CI checks continue to function correctly.

### Actual behavior

With the release of `mistune` 2.0.0 on Dec 5, [an error started coming up](https://github.com/PennyLaneAI/pennylane/runs/4425983264?check_suite_focus=true#step:4:928) for the documentation checks:
```
Exception occurred:
  File "/usr/local/lib/python3.7/site-packages/m2r2.py", line 83, in <module>
    class RestBlockGrammar(mistune.BlockGrammar):
AttributeError: module 'mistune' has no attribute 'BlockGrammar'
```

### Additional information

The error seems to be related to the new mistune release, see this issue: https://github.com/CrossNox/m2r2/issues/40.

The suggested solution is to pin `mistune`.

### Source code

_No response_

### Tracebacks

_No response_

### System information

```shell
(Building the docs on the CI.)
```


### 

- [X] I have searched existing GitHub issues to make sure the issue does not already exist.


Discussion:

Resolving patch:
diff --git a/pennylane/devices/default_mixed.py b/pennylane/devices/default_mixed.py
--- a/pennylane/devices/default_mixed.py
+++ b/pennylane/devices/default_mixed.py
@@ -95,6 +95,7 @@ class DefaultMixed(QubitDevice):
         "DepolarizingChannel",
         "BitFlip",
         "PhaseFlip",
+        "PauliError",
         "ResetError",
         "QubitChannel",
         "SingleExcitation",
diff --git a/pennylane/ops/channel.py b/pennylane/ops/channel.py
--- a/pennylane/ops/channel.py
+++ b/pennylane/ops/channel.py
@@ -15,6 +15,7 @@
 This module contains the available built-in noisy
 quantum channels supported by PennyLane, as well as their conventions.
 """
+import warnings
 import numpy as np
 
 from pennylane.operation import AnyWires, Channel
@@ -377,6 +378,102 @@ def _kraus_matrices(cls, *params):
         return [K0, K1, K2, K3, K4]
 
 
+class PauliError(Channel):
+    r"""PauliError(operators, p, wires)
+    Arbitrary number qubit, arbitrary Pauli operator error channel.
+
+    This channel is modelled by the following Kraus matrices:
+
+    .. math::
+        K_0 = \sqrt{1-p} * I
+
+    .. math::
+        K_1 = \sqrt{p} * (K_{w0} \otimes K_{w1} \otimes \dots K_{wn})
+
+    Where :math:`I` is the Identity,
+    and :math:`\otimes` denotes the Kronecker Product,
+    and :math:`K_{wi}` denotes the Kraus matrix corresponding to the operator acting on wire :math:`wi`,
+    and :math:`p` denotes the probability with which the channel is applied.
+
+    .. warning::
+
+        The size of the Kraus matrices for PauliError scale exponentially
+        with the number of wires, the channel acts on. Simulations with
+        PauliError can result in a significant increase in memory and
+        computational usage. Use with caution!
+
+    **Details:**
+
+    * Number of wires: Any (the operation can act on any number of wires)
+    * Number of parameters: 3
+
+    Args:
+        operators (str): The Pauli operators acting on the specified (groups of) wires
+        p (float): The probability of the operator being applied
+        wires (Sequence[int] or int): The wires the channel acts on
+
+    **Example:**
+
+    >>> pe = PauliError("X", 0.5, wires=0)
+    >>> km = pe.kraus_matrices
+    >>> km[0]
+    array([[0.70710678, 0.        ],
+           [0.        , 0.70710678]])
+    >>> km[1]
+        array([[0.        , 0.70710678],
+               [0.70710678, 0.        ]])
+    """
+
+    num_params = 2
+    num_wires = AnyWires
+    par_domain = "L"
+
+    ops = {
+        "X": np.array([[0, 1], [1, 0]]),
+        "Y": np.array([[0, -1j], [1j, 0]]),
+        "Z": np.array([[1, 0], [0, -1]]),
+    }
+
+    def __init__(self, *params, wires=None, do_queue=True):
+        super().__init__(*params, wires=wires, do_queue=do_queue)
+        operators, p = params[0], params[1]
+
+        # check if the specified operators are legal
+        if not all(c in "XYZ" for c in operators):
+            raise ValueError("The specified operators need to be either of 'X', 'Y' or 'Z'")
+
+        # check if probabilities are legal
+        if not 0.0 <= p <= 1.0:
+            raise ValueError("p must be between [0,1]")
+
+        # check if the number of operators matches the number of wires
+        if len(self.wires) != len(operators):
+            raise ValueError("The number of operators must match the number of wires")
+
+        nq = len(self.wires)
+
+        if nq > 20:
+            warnings.warn(
+                f"The resulting Kronecker matrices will have dimensions {2**(nq)} x {2**(nq)}.\nThis equals {2**nq*2**nq*8/1024**3} GB of physical memory for each matrix."
+            )
+
+    @classmethod
+    def _kraus_matrices(cls, *params):
+        operators, p = params[0], params[1]
+
+        nq = len(operators)
+
+        # K0 is sqrt(1-p) * Identity
+        K0 = np.sqrt(1 - p) * np.eye(2 ** nq)
+
+        # K1 is composed by Kraus matrices of operators
+        K1 = np.sqrt(p) * np.array([1])
+        for op in operators[::-1]:
+            K1 = np.kron(cls.ops[op], K1)
+
+        return [K0, K1]
+
+
 class PhaseFlip(Channel):
     r"""PhaseFlip(p, wires)
     Single-qubit bit flip (Pauli :math:`Z`) error channel.
@@ -632,6 +729,7 @@ def _kraus_matrices(cls, *params):
     "DepolarizingChannel",
     "BitFlip",
     "PhaseFlip",
+    "PauliError",
     "ResetError",
     "QubitChannel",
     "ThermalRelaxationError",


Test patch:
diff --git a/tests/devices/test_default_mixed.py b/tests/devices/test_default_mixed.py
--- a/tests/devices/test_default_mixed.py
+++ b/tests/devices/test_default_mixed.py
@@ -29,6 +29,7 @@
     AmplitudeDamping,
     DepolarizingChannel,
     ResetError,
+    PauliError,
 )
 from pennylane.wires import Wires
 
@@ -115,6 +116,8 @@ def test_state_after_twoqubit(self, nr_wires, op, tol):
             AmplitudeDamping(0.5, wires=0),
             DepolarizingChannel(0.5, wires=0),
             ResetError(0.1, 0.5, wires=0),
+            PauliError("X", 0.5, wires=0),
+            PauliError("ZY", 0.3, wires=[1, 0]),
         ],
     )
     def test_state_after_channel(self, nr_wires, op, tol):
@@ -162,6 +165,8 @@ def test_reset_after_twoqubit(self, nr_wires, op, tol):
             AmplitudeDamping(0.5, wires=[0]),
             DepolarizingChannel(0.5, wires=[0]),
             ResetError(0.1, 0.5, wires=[0]),
+            PauliError("X", 0.5, wires=0),
+            PauliError("ZY", 0.3, wires=[1, 0]),
         ],
     )
     def test_reset_after_channel(self, nr_wires, op, tol):
@@ -253,6 +258,14 @@ class TestKrausOps:
         (PauliX, np.array([[0, 1], [1, 0]])),
         (Hadamard, np.array([[INV_SQRT2, INV_SQRT2], [INV_SQRT2, -INV_SQRT2]])),
         (CNOT, np.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0]])),
+        (
+            PauliError("X", 0.5, wires=0),
+            [np.sqrt(0.5) * np.eye(2), np.sqrt(0.5) * np.array([[0, 1], [1, 0]])],
+        ),
+        (
+            PauliError("Y", 0.3, wires=0),
+            [np.sqrt(0.7) * np.eye(2), np.sqrt(0.3) * np.array([[0, -1j], [1j, 0]])],
+        ),
     ]
 
     @pytest.mark.parametrize("ops", unitary_ops)
@@ -269,7 +282,7 @@ def test_unitary_kraus(self, ops, tol):
 
     @pytest.mark.parametrize("ops", diagonal_ops)
     def test_diagonal_kraus(self, ops, tol):
-        """Tests that matrices of non-diagonal unitary operations are retrieved correctly"""
+        """Tests that matrices of diagonal unitary operations are retrieved correctly"""
         dev = qml.device("default.mixed", wires=2)
 
         assert np.allclose(dev._get_kraus(ops[0]), ops[1], atol=tol, rtol=0)
@@ -301,6 +314,10 @@ def test_diagonal_kraus(self, ops, tol):
                 np.sqrt(p_1) * np.array([[0, 0], [0, 1]]),
             ],
         ),
+        (
+            PauliError("X", p_0, wires=0),
+            [np.sqrt(1 - p_0) * np.eye(2), np.sqrt(p_0) * np.array([[0, 1], [1, 0]])],
+        ),
     ]
 
     @pytest.mark.parametrize("ops", channel_ops)
@@ -329,6 +346,8 @@ class TestApplyChannel:
             ResetError(0.1, 0.5, wires=0),
             np.array([[0.5 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 0.5 + 0.0j]]),
         ],
+        [1, PauliError("Z", 0.3, wires=0), basis_state(0, 1)],
+        [2, PauliError("XY", 0.5, wires=[0, 1]), 0.5 * basis_state(0, 2) + 0.5 * basis_state(3, 2)],
     ]
 
     @pytest.mark.parametrize("x", x_apply_channel_init)
@@ -339,7 +358,7 @@ def test_channel_init(self, x, tol):
         target_state = np.reshape(x[2], [2] * 2 * nr_wires)
         dev = qml.device("default.mixed", wires=nr_wires)
         kraus = dev._get_kraus(op)
-        if op == CNOT:
+        if op == CNOT or (isinstance(op, PauliError) and nr_wires == 2):
             dev._apply_channel(kraus, wires=Wires([0, 1]))
         else:
             dev._apply_channel(kraus, wires=Wires(0))
@@ -365,6 +384,8 @@ def test_channel_init(self, x, tol):
             ResetError(0.1, 0.5, wires=0),
             np.array([[0.3 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 0.7 + 0.0j]]),
         ],
+        [1, PauliError("Z", 0.3, wires=0), max_mixed_state(1)],
+        [2, PauliError("XY", 0.5, wires=[0, 1]), max_mixed_state(2)],
     ]
 
     @pytest.mark.parametrize("x", x_apply_channel_mixed)
@@ -377,7 +398,7 @@ def test_channel_mixed(self, x, tol):
         max_mixed = np.reshape(max_mixed_state(nr_wires), [2] * 2 * nr_wires)
         dev._state = max_mixed
         kraus = dev._get_kraus(op)
-        if op == CNOT:
+        if op == CNOT or (isinstance(op, PauliError) and nr_wires == 2):
             dev._apply_channel(kraus, wires=Wires([0, 1]))
         else:
             dev._apply_channel(kraus, wires=Wires(0))
@@ -414,6 +435,23 @@ def test_channel_mixed(self, x, tol):
             ResetError(0.1, 0.5, wires=0),
             np.array([[0.3 + 0.0j, -0.2 + 0.0j], [-0.2 + 0.0j, 0.7 + 0.0j]]),
         ],
+        [
+            1,
+            PauliError("Z", 0.3, wires=0),
+            np.array([[0.5 + 0.0j, -0.2 + 0.0j], [-0.2 + 0.0j, 0.5 + 0.0j]]),
+        ],
+        [
+            2,
+            PauliError("XY", 0.5, wires=[0, 1]),
+            np.array(
+                [
+                    [0.25 + 0.0j, 0.0 - 0.25j, -0.25 + 0.0j, 0.0 + 0.25j],
+                    [0.0 + 0.25j, 0.25 + 0.0j, 0.0 - 0.25j, -0.25 + 0.0j],
+                    [-0.25 + 0.0j, 0.0 + 0.25j, 0.25 + 0.0j, 0.0 - 0.25j],
+                    [0.0 - 0.25j, -0.25 + 0.0j, 0.0 + 0.25j, 0.25 + 0.0j],
+                ]
+            ),
+        ],
     ]
 
     @pytest.mark.parametrize("x", x_apply_channel_root)
@@ -426,7 +464,7 @@ def test_channel_root(self, x, tol):
         root = np.reshape(root_state(nr_wires), [2] * 2 * nr_wires)
         dev._state = root
         kraus = dev._get_kraus(op)
-        if op == CNOT:
+        if op == CNOT or (isinstance(op, PauliError) and nr_wires == 2):
             dev._apply_channel(kraus, wires=Wires([0, 1]))
         else:
             dev._apply_channel(kraus, wires=Wires(0))
@@ -901,6 +939,16 @@ def test_apply_qubitunitary(self, tol):
 
         assert np.allclose(dev.state, target_rho, atol=tol, rtol=0)
 
+    def test_apply_pauli_error(self, tol):
+        """Tests that PauliError gate is correctly applied"""
+        nr_wires = 3
+        p = 0.3
+        dev = qml.device("default.mixed", wires=nr_wires)
+        dev.apply([PauliError("XYZ", p, wires=[0, 1, 2])])
+        target = 0.7 * basis_state(0, 3) + 0.3 * basis_state(6, 3)
+
+        assert np.allclose(dev.state, target, atol=tol, rtol=0)
+
 
 class TestInit:
     """Tests related to device initializtion"""
diff --git a/tests/ops/test_channel_ops.py b/tests/ops/test_channel_ops.py
--- a/tests/ops/test_channel_ops.py
+++ b/tests/ops/test_channel_ops.py
@@ -19,7 +19,7 @@
 import numpy as np
 import pennylane as qml
 from pennylane.ops import channel
-from pennylane.wires import Wires
+from pennylane.wires import WireError
 
 X = np.array([[0, 1], [1, 0]])
 Y = np.array([[0, -1j], [1j, 0]])
@@ -33,6 +33,7 @@
     channel.PhaseFlip,
     channel.DepolarizingChannel,
     channel.ResetError,
+    channel.PauliError,
     channel.ThermalRelaxationError,
 ]
 
@@ -46,17 +47,20 @@ class TestChannels:
     def test_kraus_matrices_sum_identity(self, ops, p, tr_args, tol):
         """Test channels are trace-preserving"""
         if ops.__name__ == "GeneralizedAmplitudeDamping":
-            op = ops(p, p, wires=0)
+            op = [ops(p, p, wires=0)]
         elif ops.__name__ == "ResetError":
-            op = ops(p / 2, p / 3, wires=0)
+            op = [ops(p / 2, p / 3, wires=0)]
+        elif ops.__name__ == "PauliError":
+            op = [ops("X", p, wires=0), ops("XX", p, wires=[0, 1])]
         elif ops.__name__ == "ThermalRelaxationError":
-            op = ops(p, *tr_args, wires=0)
+            op = [ops(p, *tr_args, wires=0)]
         else:
-            op = ops(p, wires=0)
-        K_list = op.kraus_matrices
-        K_arr = np.array(K_list)
-        Kraus_sum = np.einsum("ajk,ajl->kl", K_arr.conj(), K_arr)
-        assert np.allclose(Kraus_sum, np.eye(2), atol=tol, rtol=0)
+            op = [ops(p, wires=0)]
+        for operation in op:
+            K_list = operation.kraus_matrices
+            K_arr = np.array(K_list)
+            Kraus_sum = np.einsum("ajk,ajl->kl", K_arr.conj(), K_arr)
+            assert np.allclose(Kraus_sum, np.eye(K_list[0].shape[0]), atol=tol, rtol=0)
 
 
 class TestAmplitudeDamping:
@@ -318,6 +322,102 @@ def circuit(p_0, p_1):
         )
 
 
+class TestPauliError:
+    """Tests for the quantum channel PauliError"""
+
+    OPERATORS_WRONG_PARAMS = ["XXX", "XXX", "ABC", "XXX"]
+    P_WRONG_PARAMS = [0.5, 1.5, 0.5, 0.5]
+    WIRES_WRONG_PARAMS = [[0], [0, 1, 2], [0, 1, 2], [1, 1, 2]]
+    EXPECTED_ERRORS = [ValueError, ValueError, ValueError, WireError]
+    EXPECTED_MESSAGES = [
+        "The number of operators must match the number of wires",
+        "p must be between \\[0,1\\]",
+        "The specified operators need to be either of 'X', 'Y' or 'Z'",
+        "Wires must be unique",
+    ]
+
+    @pytest.mark.parametrize(
+        "operators, p, wires, error, message",
+        list(
+            zip(
+                OPERATORS_WRONG_PARAMS,
+                P_WRONG_PARAMS,
+                WIRES_WRONG_PARAMS,
+                EXPECTED_ERRORS,
+                EXPECTED_MESSAGES,
+            )
+        ),
+    )
+    def test_wrong_parameters(self, operators, p, wires, error, message):
+        """Test wrong parametrizations of PauliError"""
+        with pytest.raises(error, match=message):
+            Ks = channel.PauliError(operators, p, wires=wires)
+
+    def test_warning_many_qubits(self):
+        """Test if warning is thrown when huge matrix"""
+        with pytest.warns(UserWarning):
+            Ks = channel.PauliError("X" * 512, 0.5, wires=list(range(512)))
+
+    def test_p_zero(self, tol):
+        """Test resulting Kraus matrices for p=0"""
+        expected_Ks = [np.eye(2 ** 5), np.zeros((2 ** 5, 2 ** 5))]
+        c = channel.PauliError("XXXXX", 0, wires=[0, 1, 2, 3, 4])
+
+        assert np.allclose(c.kraus_matrices, expected_Ks, atol=tol, rtol=0)
+
+    def test_p_one(self, tol):
+        """Test resulting Kraus matrices for p=1"""
+        expected_Ks = [np.zeros((2 ** 5, 2 ** 5)), np.flip(np.eye(2 ** 5), axis=1)]
+        c = channel.PauliError("XXXXX", 1, wires=[0, 1, 2, 3, 4])
+
+        assert np.allclose(c.kraus_matrices, expected_Ks, atol=tol, rtol=0)
+
+    OPERATORS = ["X", "XY", "ZX"]
+    WIRES = [[1], [0, 1], [3, 1]]
+    EXPECTED_KS = [
+        [
+            np.sqrt(0.5) * np.eye(2),
+            np.array(
+                [
+                    [0.0, 0.70710678],
+                    [0.70710678, 0.0],
+                ]
+            ),
+        ],
+        [
+            np.sqrt(0.5) * np.eye(4),
+            np.array(
+                [
+                    [0.0 + 0.0j, 0.0 + 0.0j, 0.0 + 0.0j, 0.0 - 0.70710678j],
+                    [0.0 + 0.0j, 0.0 + 0.0j, 0.0 + 0.70710678j, 0.0 + 0.0j],
+                    [0.0 + 0.0j, 0.0 - 0.70710678j, 0.0 + 0.0j, 0.0 + 0.0j],
+                    [0.0 + 0.70710678j, 0.0 + 0.0j, 0.0 + 0.0j, 0.0 + 0.0j],
+                ]
+            ),
+        ],
+        [
+            np.sqrt(0.5) * np.eye(4),
+            np.array(
+                [
+                    [0.0, 0.70710678, 0.0, 0.0],
+                    [0.70710678, 0.0, 0.0, 0.0],
+                    [0.0, 0.0, -0.0, -0.70710678],
+                    [0.0, 0.0, -0.70710678, -0.0],
+                ]
+            ),
+        ],
+    ]
+
+    @pytest.mark.parametrize(
+        "operators, wires, expected_Ks", list(zip(OPERATORS, WIRES, EXPECTED_KS))
+    )
+    def test_kraus_matrix(self, tol, operators, wires, expected_Ks):
+        """Test sevaral resulting kraus matrices for sevaral configurations"""
+        c = channel.PauliError(operators, 0.5, wires=wires)
+
+        assert np.allclose(c.kraus_matrices, expected_Ks, atol=tol, rtol=0)
+
+
 class TestQubitChannel:
     """Tests for the quantum channel QubitChannel"""