Repository: PennyLaneAI/pennylane

Issue:
Help device plugin developers deal with the Operation refactor
### Feature details

As was discussed in #2023 with @josh146  and @mariaschuld in the light of the `Operation` refactor a smooth transition with useful deprecation warnings should be ensured so that device developers have time to update their devices and custom `Operation`s and support at least a few versions of PennyLane.

Currently this has not been fully achieved.

Assume that some third party code (e.g a device plugin) defines a "old style" custom `Operation` as follows:
```
import pennylane as qml
from pennylane.operation import Operation

class MyOp(Operation):
    num_wires = 1
    num_params = 2
    par_domain = "R"
    grad_method = "F"

    @staticmethod
    def decomposition(*params, wires):
        qml.RY(params[0], wires=wires[0])
        qml.PauliZ(wires=wires[0])
        qml.RX(params[1], wires=wires[0])

dev = qml.device('default.qubit', wires=1)

@qml.qnode(dev)
def qnode(*params):
    MyOp(*params, wires=0)
    return qml.state()

print(qml.draw(qnode)(0.1, 0.2))
print(qnode(0.1, 0.2))
``` 
This works until v0.21.0 but with master the user gets the unspecific error message:
```
TypeError: decomposition() missing 1 required keyword-only argument: 'wires'
```
Converting the implementation to "new style" will break the code with all PL versions prior to and including v0.21.0.

This is not nice. 

Due to the smart re-naming of functions in #2023 it is indeed possible to define the above operation in a way that is compatible with both the old and the new style:
```
    def decomposition(self, *params, wires=None):
        if wires is None:
            wires = self.wires
        if not params:
            params = self.parameters
        qml.RY(params[0], wires=wires[0])
        qml.PauliZ(wires=wires[0])
        qml.RX(params[1], wires=wires[0])
```
Alternatively one can also directly implement the new `compute_decomposition()` and patch in a suitable `preparation()` if one is running on an old PL version. The following also works with both 0.21 and the current master:
```
class MyOp(Operation):
    num_wires = 1
    num_params = 2
    par_domain = "R"
    grad_method = "F"

    if not hasattr(Operation, 'get_eigvals'): # pennylane <= v0.21
        @classmethod
        def decomposition(cls, *params, wires):
            return cls.compute_decomposition(*params, wires=wires)

    @staticmethod
    def compute_decomposition(*params, wires=None):
        qml.RY(params[0], wires=wires[0])
        qml.PauliZ(wires=wires[0])
        qml.RX(params[1], wires=wires[0])
```
There are of course a few further variants...

It would be nice to replace the above unspecific error message with a useful error that gives a hint for how the implementations can be made compatible across the 0.21/0.22 cut so that plugin developers can at all times support at least some range of PennyLane versions.

### Implementation

_No response_

### How important would you say this feature is?

3: Very important! Blocking work.

### Additional information

_No response_


Discussion:
Thanks @cvjjm for reporting this!

Just double checking, but is `Operator.decomposition` the only changed method with this issue? Or are there others as well?

From having a look at your code,

```python
def decomposition(self, *params, wires=None):
    if wires is None:
        wires = self.wires
    if not params:
        params = self.parameters
    qml.RY(params[0], wires=wires[0])
    qml.PauliZ(wires=wires[0])
    qml.RX(params[1], wires=wires[0])
```

is a nice (and relatively simply to read) workaround, I'll see if there is a nice way to catch this (might mean tracking down everywhere that `Operator.decomposition()` is currently called).

Resolving patch:
diff --git a/pennylane/gradients/gradient_transform.py b/pennylane/gradients/gradient_transform.py
--- a/pennylane/gradients/gradient_transform.py
+++ b/pennylane/gradients/gradient_transform.py
@@ -47,7 +47,7 @@ def grad_method_validation(method, tape):
 
     diff_methods = {
         idx: info["grad_method"]
-        for idx, info in tape._par_info.items()
+        for idx, info in tape._par_info.items()  # pylint: disable=protected-access
         if idx in tape.trainable_params
     }
 
diff --git a/pennylane/operation.py b/pennylane/operation.py
--- a/pennylane/operation.py
+++ b/pennylane/operation.py
@@ -1266,7 +1266,22 @@ def expand(self):
         tape = qml.tape.QuantumTape(do_queue=False)
 
         with tape:
-            self.decomposition()
+
+            try:
+                self.decomposition()
+
+            except TypeError:
+                if self.num_params == 0:
+                    self.decomposition(wires=self.wires)
+                else:
+                    self.decomposition(*self.parameters, wires=self.wires)
+
+                warnings.warn(
+                    "Operator.decomposition() is now an instance method, and no longer accepts parameters. "
+                    "Either define the static method 'compute_decomposition' instead, or use "
+                    "'self.wires' and 'self.parameters'.",
+                    UserWarning,
+                )
 
         if not self.data:
             # original operation has no trainable parameters


Test patch:
diff --git a/tests/test_operation.py b/tests/test_operation.py
--- a/tests/test_operation.py
+++ b/tests/test_operation.py
@@ -16,6 +16,7 @@
 """
 import itertools
 from functools import reduce
+import warnings
 
 import pytest
 import numpy as np
@@ -1845,3 +1846,91 @@ def test_eigvals_tensor_deprecation(self):
             m1 = op.eigvals
 
         assert np.allclose(m1, op.get_eigvals())
+
+    def test_decomposition_deprecation_no_parameters(self):
+        """Test that old-style staticmethod decompositions for an operation
+        with no parameters raises a warning"""
+        dev = qml.device("default.qubit", wires=1)
+
+        class MyOp(Operation):
+            num_wires = 1
+            num_params = 0
+
+            @staticmethod
+            def decomposition(wires):
+                qml.RY(0.5, wires=wires[0])
+
+        @qml.qnode(dev)
+        def qnode():
+            MyOp(wires=0)
+            return qml.state()
+
+        with pytest.warns(UserWarning, match="is now an instance method"):
+            result1 = qnode()
+
+        # using an instance method will not raise a deprecation warning
+
+        class MyOp(Operation):
+            num_wires = 1
+            num_params = 0
+
+            def decomposition(self):
+                qml.RY(0.5, wires=self.wires[0])
+
+        @qml.qnode(dev)
+        def qnode():
+            MyOp(wires=0)
+            return qml.state()
+
+        with warnings.catch_warnings():
+            # any warnings emitted will be raised as errors
+            warnings.simplefilter("error")
+            result2 = qnode()
+
+        assert np.allclose(result1, result2)
+
+    def test_decomposition_deprecation_parameters(self):
+        """Test that old-style staticmethod decompositions for an operation
+        with parameters raises a warning"""
+        dev = qml.device("default.qubit", wires=1)
+
+        class MyOp(Operation):
+            num_wires = 1
+            num_params = 2
+
+            @staticmethod
+            def decomposition(*params, wires):
+                qml.RY(params[0], wires=wires[0])
+                qml.PauliZ(wires=wires[0])
+                qml.RX(params[1], wires=wires[0])
+
+        @qml.qnode(dev)
+        def qnode(*params):
+            MyOp(*params, wires=0)
+            return qml.state()
+
+        with pytest.warns(UserWarning, match="is now an instance method"):
+            result1 = qnode(0.1, 0.2)
+
+        # using an instance method will not raise a deprecation warning
+
+        class MyOp(Operation):
+            num_wires = 1
+            num_params = 2
+
+            def decomposition(self):
+                qml.RY(self.parameters[0], wires=self.wires[0])
+                qml.PauliZ(wires=self.wires[0])
+                qml.RX(self.parameters[1], wires=self.wires[0])
+
+        @qml.qnode(dev)
+        def qnode(*params):
+            MyOp(*params, wires=0)
+            return qml.state()
+
+        with warnings.catch_warnings():
+            # any warnings emitted will be raised as errors
+            warnings.simplefilter("error")
+            result2 = qnode(0.1, 0.2)
+
+        assert np.allclose(result1, result2)