Repository: PennyLaneAI/pennylane
Pull request #7876 (2025-07-14 21:01:06)

New set shots
**Context:**
`@partial(qml.set_shots, shots=...)` seems too complicated and user-unfriendly. So we would like to have the alias that `@qml.set_shots` got applied on the qnode directly.

**Description of the Change:**
Extended the fucntionality of the original `set_shots` to be able to handle the case where it's used as a general decorator so that it returned a fuction.

**Benefits:**
`@set_shots` is live now.
Plus, it co-exists with previous usage, so one can still do `@partial(qml.set_shots, shots=...)` as wish.

**Possible Drawbacks:**
Maybe we can check if the recursive usage is dangerous? It smells not bad to me but maybe there will be certain corner cases.

**Related GitHub Issues:**
[sc-95448]

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7876?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
Attention: Patch coverage is `87.50000%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 99.68%. Comparing base [(`ea0364b`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/ea0364b31ec8244c75af9150dbac5185d2fe8998?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`c3b6b6c`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/c3b6b6cbeaeabe0c6e446d6c93f8e793abc94b35?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).

| [Files with missing lines](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7876?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) | Patch % | Lines |
|---|---|---|
| [pennylane/workflow/set\_shots.py](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7876?src=pr&el=tree&filepath=pennylane%2Fworkflow%2Fset_shots.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL3dvcmtmbG93L3NldF9zaG90cy5weQ==) | 87.50% | [1 Missing :warning: ](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7876?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #7876      +/-   ##
==========================================
- Coverage   99.68%   99.68%   -0.01%     
==========================================
  Files         543      543              
  Lines       55371    55378       +7     
==========================================
+ Hits        55197    55203       +6     
- Misses        174      175       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7876?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 720feb105..61a6f1381 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -4,15 +4,34 @@
 
 <h3>New features since last release</h3>
 
-<h4>OpenQASM 🤝 PennyLane</h4>
+<h3>Improvements 🛠</h3>
+
+<h4>OpenQASM-PennyLane interoperability</h4>
 
-* More functionality within :func:`qml.from_qasm3` has been added, which converts more complex OpenQASM 3.0 circuits 
-  into quantum functions that can be subsequently loaded into QNodes and executed. For more details, 
-  please consult the documentation for :func:`qml.from_qasm3`.
+* The :func:`qml.from_qasm3` function can now convert OpenQASM 3.0 circuits that contain
+  subroutines, constants, and built-in mathematical functions.
   [(#7651)](https://github.com/PennyLaneAI/pennylane/pull/7651)
   [(#7653)](https://github.com/PennyLaneAI/pennylane/pull/7653)
-
-<h3>Improvements 🛠</h3>
+  [(#7676)](https://github.com/PennyLaneAI/pennylane/pull/7676)
+  [(#7677)](https://github.com/PennyLaneAI/pennylane/pull/7677)
+
+<h4>Other improvements</h4>
+
+* The :func:`qml.workflow.set_shots` transform can now be directly applied to a QNode without the need for `functools.partial`, providing a more user-friendly syntax and negating having to import the `functools` package.
+  [(#7876)](https://github.com/PennyLaneAI/pennylane/pull/7876)
+  
+  ```python
+  @qml.set_shots(shots=1000)
+  @qml.qnode(dev)
+  def circuit():
+      qml.H(0)
+      return qml.expval(qml.Z(0))
+  ```
+
+  ```pycon
+  >>> circuit()
+  0.002
+  ```
 
 * Update minimum supported `pytest` version to `8.4.1`.
   [(#7853)](https://github.com/PennyLaneAI/pennylane/pull/7853)
@@ -63,6 +82,9 @@
 
 <h3>Bug fixes 🐛</h3>
 
+* Calling `QNode.update` no longer acts as if `set_shots` has been applied.
+  [(#7881)](https://github.com/PennyLaneAI/pennylane/pull/7881)
+
 * Fixes attributes and types in the quantum dialect.
   This allows for types to be inferred correctly when parsing.
   [(#7825)](https://github.com/PennyLaneAI/pennylane/pull/7825)
diff --git a/pennylane/io/io.py b/pennylane/io/io.py
index 994fd47fc..e5863549e 100644
--- a/pennylane/io/io.py
+++ b/pennylane/io/io.py
@@ -848,9 +848,10 @@ def from_qasm3(quantum_circuit: str, wire_map: dict = None):
     Converts an OpenQASM 3.0 circuit into a quantum function that can be used within a QNode.
 
     .. note::
-        The following OpenQASM 3.0 gates are not supported: sdg, tdg, cu. Built-in mathematical functions and
-        constants, custom gates, and pulses are not yet supported. The remaining standard library gates,
-        subroutines, variables, control flow, measurements, inputs, outputs and ``end`` statements are all supported.
+        The following OpenQASM 3.0 gates are not supported: sdg, tdg, cu. Built-in mathematical functions
+        as well as pulses are not yet supported. The remaining standard library gates, constants,
+        subroutines, variables, control flow, measurements, inputs, outputs, custom gates and ``end`` statements
+        are all supported.
 
         In order to use this function, ``openqasm3`` and ``'openqasm3[parser]'`` must be installed in the user's
         environment. Please consult the `OpenQASM installation instructions <https://pypi.org/project/openqasm3>`__
diff --git a/pennylane/io/qasm_interpreter.py b/pennylane/io/qasm_interpreter.py
index eefc6828a..d2f9581b7 100644
--- a/pennylane/io/qasm_interpreter.py
+++ b/pennylane/io/qasm_interpreter.py
@@ -2,11 +2,13 @@
 This submodule contains the interpreter for OpenQASM 3.0.
 """
 
+import copy
 import functools
 from dataclasses import dataclass
 from functools import partial
 from typing import Any, Callable, Iterable
 
+import numpy as np
 from numpy import uint
 from openqasm3 import ast
 from openqasm3.visitor import QASMNode
@@ -50,6 +52,15 @@ PARAMETERIZED_GATES = {
     "CRZ": ops.CRZ,
 }
 
+CONSTANTS = {
+    "π": np.pi,
+    "τ": np.pi * 2,
+    "ℇ": np.e,
+    "pi": np.pi,
+    "tau": np.pi * 2,
+    "e": np.e,
+}
+
 
 def _eval_unary_op(operand: any, operator: str, line: int):
     """
@@ -215,13 +226,37 @@ class Context:
         if "wires" not in context:
             context["wires"] = []
         if "scopes" not in context:
-            context["scopes"] = {"subroutines": {}}
+            context["scopes"] = {"subroutines": {}, "custom_gates": {}}
         if "wire_map" not in context or context["wire_map"] is None:
             context["wire_map"] = {}
         if "return" not in context:
             context["return"] = {}
         self.context = context
 
+    def init_custom_gate_scope(self, node: ast.QuantumGateDefinition):
+        """
+        Initializes a context for a custom quantum gate.
+
+        Args:
+            node (QuantumGateDefinition): the custom quantum gate definition.
+        """
+        self.scopes["custom_gates"][node.name.name] = Context(
+            {
+                "vars": {k: v for k, v in self.vars.items() if v.constant},
+                "wire_map": {},
+                "body": node.body,
+                "params": [_resolve_name(param) for param in node.arguments]
+                + [_resolve_name(qubit) for qubit in node.qubits],
+                "wires": copy.deepcopy(self.wires),
+                "name": node.name.name,
+                # we want subroutines declared in the global scope to be available
+                "scopes": {
+                    "subroutines": self.scopes["subroutines"],
+                    "custom_gates": self.scopes["custom_gates"],
+                },
+            }
+        )
+
     def init_subroutine_scope(self, node: ast.SubroutineDefinition):
         """
         Initializes a sub context with all the params, constants, subroutines and qubits it has access to.
@@ -235,10 +270,13 @@ class Context:
             {
                 "vars": {k: v for k, v in self.vars.items() if v.constant},  # same namespace
                 "wire_map": {},
-                "wires": self.wires,
+                "wires": copy.deepcopy(self.wires),
                 "name": node.name.name,
                 # we want subroutines declared in the global scope to be available
-                "scopes": {"subroutines": self.scopes["subroutines"]},
+                "scopes": {
+                    "subroutines": self.scopes["subroutines"],
+                    "custom_gates": self.scopes["custom_gates"],
+                },
                 "body": node.body,
                 "params": [param.name.name for param in node.arguments],
             }
@@ -268,6 +306,8 @@ class Context:
             return name
         if name in self.aliases:
             return self.aliases[name](self)  # evaluate the alias and de-reference
+        if name in CONSTANTS:
+            return CONSTANTS[name]
         raise TypeError(f"Attempt to use undeclared variable {name} in {self.name}")
 
     def update_var(
@@ -734,6 +774,40 @@ class QasmInterpreter:
         wire = self.visit(node.qubits, context)
         measure(context.wire_map.get(wire, wire), reset=True)
 
+    def execute_custom_gate(self, node: ast.QuantumGate, context: Context):
+        """
+        Executes a custom gate.
+
+        Args:
+            node (QuantumGate): the custom gate call.
+            context (Context): the current context.
+        """
+
+        gate_context = context.scopes["custom_gates"][_resolve_name(node)]
+
+        # bind subroutine arguments
+        evald_args = [self.visit(raw_arg, context) for raw_arg in node.arguments] + [
+            _resolve_name(qubit) for qubit in node.qubits
+        ]
+        for evald_arg, param in list(zip(evald_args, gate_context.params)):
+            if not isinstance(evald_arg, str):  # this would indicate a quantum parameter
+                gate_context.vars[param] = Variable(
+                    evald_arg.__class__.__name__, evald_arg, None, node.span.start_line, False
+                )
+            else:
+                if evald_arg in context.wire_map:
+                    evald_arg = context.wire_map[evald_arg]
+                if param != evald_arg:
+                    gate_context.wire_map[param] = evald_arg
+
+        # execute the subroutine
+        self.visit(gate_context.body, gate_context)
+
+        # reset context
+        gate_context.vars = {k: v for k, v in gate_context.vars.items() if v.constant}
+
+        # custom gates do not return a value
+
     @visit.register(ast.FunctionCall)
     def visit_function_call(self, node: ast.FunctionCall, context: Context):
         """
@@ -1013,6 +1087,31 @@ class QasmInterpreter:
         """
         return [self.visit(literal, context) for literal in node.values]
 
+    @visit.register(ast.QuantumGateDefinition)
+    def visit_quantum_gate_definition(self, node: ast.QuantumGateDefinition, context: Context):
+        """
+        Registers a quantum gate definition.
+
+        Args:
+            node (QuantumGateDefinition): The quantum gate definition QASMNode.
+            context (Context): the current context.
+        """
+        context.init_custom_gate_scope(node)
+
+        # register the params
+        for param in node.arguments:
+            context.scopes["custom_gates"][_resolve_name(node)].vars[_resolve_name(param)] = (
+                Variable(
+                    ty=param.__class__.__name__,
+                    val=None,
+                    size=-1,
+                    line=param.span.start_line,
+                    constant=False,
+                )
+            )
+        for qubit in node.qubits:
+            context.scopes["custom_gates"][_resolve_name(node)].wires.append(_resolve_name(qubit))
+
     @visit.register(ast.SubroutineDefinition)
     def visit_subroutine_definition(self, node: ast.SubroutineDefinition, context: Context):
         """
@@ -1061,6 +1160,9 @@ class QasmInterpreter:
             gates_dict = PARAMETERIZED_GATES
         elif name in NON_PARAMETERIZED_GATES:
             gates_dict = NON_PARAMETERIZED_GATES
+        elif name in [n.upper() for n in context.scopes["custom_gates"].keys()]:
+            self.execute_custom_gate(node, context)
+            return
         else:
             raise NotImplementedError(f"Unsupported gate encountered in QASM: {node.name.name}")
 
diff --git a/pennylane/templates/subroutines/flip_sign.py b/pennylane/templates/subroutines/flip_sign.py
index 2009665b2..e832ad1bc 100644
--- a/pennylane/templates/subroutines/flip_sign.py
+++ b/pennylane/templates/subroutines/flip_sign.py
@@ -14,8 +14,6 @@
 r"""
 Contains the FlipSign template.
 """
-import warnings
-
 import pennylane as qml
 from pennylane.operation import Operation
 
@@ -35,12 +33,6 @@ class FlipSign(Operation):
         n (array[int] or int): binary array or integer value representing the state on which to flip the sign
         wires (array[int] or int): wires that the template acts on
 
-    .. warning::
-
-        Passing an integer `m` as the wires argument is now interpreted as a single wire (i.e. wires=[`m`]).
-        This is different from the previous interpretation of wires=range(`m`). This may lead to unexpected errors.
-
-
     **Example**
 
     This template changes the sign of the basis state passed as an argument.
@@ -80,10 +72,6 @@ class FlipSign(Operation):
             raise ValueError("At least one valid wire is required.")
 
         if isinstance(wires, int):
-            warnings.warn(
-                "Passing an integer `m` as the wires argument is now interpreted as a single wire (i.e. wires=[`m`])."
-                "This is different from the previous interpretation of wires=range(`m`). This may lead to unexpected errors."
-            )
             wires = [wires]
 
         if isinstance(n, int):
diff --git a/pennylane/workflow/qnode.py b/pennylane/workflow/qnode.py
index 4756bc8d3..546ea1af2 100644
--- a/pennylane/workflow/qnode.py
+++ b/pennylane/workflow/qnode.py
@@ -734,7 +734,11 @@ class QNode:
 
         original_init_args.update(kwargs)
         updated_qn = QNode(**original_init_args)
-        updated_qn._set_shots(old_shots)  # pylint: disable=protected-access
+        # pylint: disable=protected-access
+        if updated_qn._shots != old_shots:
+            updated_qn._set_shots(old_shots)
+        if self._shots_override_device:
+            updated_qn._shots_override_device = True
 
         # pylint: disable=protected-access
         updated_qn._transform_program = qml.transforms.core.TransformProgram(self.transform_program)
diff --git a/pennylane/workflow/set_shots.py b/pennylane/workflow/set_shots.py
index 72100466d..2a7f71a6e 100644
--- a/pennylane/workflow/set_shots.py
+++ b/pennylane/workflow/set_shots.py
@@ -16,27 +16,32 @@ This module contains the set_shots decorator.
 """
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Optional, Sequence, Tuple
+from collections.abc import Callable, Sequence
+from typing import TYPE_CHECKING, Optional
 
 from .qnode import QNode
 
+# Sentinel value to distinguish decorator mode from direct calls
+_DECORATOR_MODE = object()
+
 if TYPE_CHECKING:
     from pennylane.measurements import Shots
 
 
 def set_shots(
-    qnode: QNode,
-    shots: Optional[Shots | int | Sequence[int | Tuple[int, int]]] = None,
-) -> QNode:
+    qnode: QNode | object = _DECORATOR_MODE,
+    shots: Optional[Shots | int | Sequence[int | tuple[int, int]]] = None,
+) -> QNode | Callable[[QNode], QNode]:
     """Transform used to set or update a circuit's shots.
 
     Args:
-        qnode (QNode): The QNode to transform.
+        qnode (QNode): The QNode to transform. If not provided, `set_shots` can be used as a decorator directly.
         shots (None or int or Sequence[int] or Sequence[tuple[int, int]] or pennylane.shots.Shots): The
             number of shots (or a shots vector) that the transformed circuit will execute.
 
     Returns:
-        QNode: The transformed QNode with the specified shots.
+        QNode or callable: The transformed QNode with updated shots, or a wrapper function
+        if qnode is not provided.
 
     There are three ways to specify shot values (see :func:`qml.measurements.Shots <pennylane.measurements.Shots>` for more details):
 
@@ -50,9 +55,7 @@ def set_shots(
 
     .. code-block:: python
 
-        from functools import partial
-
-        @partial(qml.set_shots, shots=2)
+        @qml.set_shots(shots=2)
         @qml.qnode(qml.device("default.qubit", wires=1))
         def circuit():
             qml.RX(1.23, wires=0)
@@ -70,7 +73,18 @@ def set_shots(
     (array([-1.,  1., -1.,  1.]), array([ 1.,  1.,  1., -1.,  1.,  1., -1., -1.,  1.,  1.]))
 
     """
-    # When called directly with a function/QNode
+    # When used as decorator with arguments: @set_shots(shots=...)
+    # This happens when qnode parameter is not provided (decorator mode)
+    if qnode is _DECORATOR_MODE:
+
+        def decorator(qnode_func):
+            return set_shots(qnode_func, shots)
+
+        return decorator
+
+    # When called directly with a QNode
     if isinstance(qnode, QNode):
         return qnode.update_shots(shots)
+
+    # If qnode is not a QNode (including explicit None), raise error
     raise ValueError("set_shots can only be applied to QNodes")


Test patch:
diff --git a/tests/io/qasm_interpreter/custom_gates.qasm b/tests/io/qasm_interpreter/custom_gates.qasm
new file mode 100644
index 000000000..2e890958f
--- /dev/null
+++ b/tests/io/qasm_interpreter/custom_gates.qasm
@@ -0,0 +1,20 @@
+qubit q0;
+qubit q1;
+
+const float pi = 3.14159;
+
+gate custom(θ) a, b
+{
+    CX a, b;
+    CX a, b;
+    rx(θ / 2) b;
+}
+
+gate custom_two(θ) c, d
+{
+    y c;
+    custom(θ / 2) d, c;
+    x d;
+}
+
+custom_two(pi) q1, q0;
diff --git a/tests/io/qasm_interpreter/test_interpreter.py b/tests/io/qasm_interpreter/test_interpreter.py
index 434db2340..2cef8fa5e 100644
--- a/tests/io/qasm_interpreter/test_interpreter.py
+++ b/tests/io/qasm_interpreter/test_interpreter.py
@@ -5,6 +5,7 @@ Unit tests for the :mod:`pennylane.io.qasm_interpreter` module.
 from re import escape
 from unittest.mock import MagicMock
 
+import numpy as np
 import pytest
 
 from pennylane import (
@@ -59,6 +60,31 @@ except (ModuleNotFoundError, ImportError) as import_error:
     pass
 
 
+@pytest.mark.external
+class TestBuiltIns:  # pylint: disable=too-few-public-methods
+
+    def test_constants(self):
+        ast = parse(
+            """
+            const float one = π;
+            const float two = τ;
+            const float three = ℇ;
+            const float four = pi;
+            const float five = tau;
+            const float six = e;
+            """
+        )
+
+        context = QasmInterpreter().interpret(ast, context={"name": "constants", "wire_map": None})
+
+        assert context.vars["one"].val == np.pi
+        assert context.vars["two"].val == np.pi * 2
+        assert context.vars["three"].val == np.e
+        assert context.vars["four"].val == np.pi
+        assert context.vars["five"].val == np.pi * 2
+        assert context.vars["six"].val == np.e
+
+
 @pytest.mark.external
 class TestIO:
 
@@ -1015,6 +1041,22 @@ class TestVariables:
 @pytest.mark.external
 class TestGates:
 
+    def test_custom_gates(self):
+        ast = parse(
+            open("tests/io/qasm_interpreter/custom_gates.qasm", mode="r").read(), permissive=True
+        )
+
+        with queuing.AnnotatedQueue() as q:
+            QasmInterpreter().interpret(ast, context={"wire_map": None, "name": "custom-gates"})
+
+        assert q.queue == [
+            PauliY("q1"),
+            CNOT(wires=["q0", "q1"]),
+            CNOT(wires=["q0", "q1"]),
+            RX(0.7853975, wires=["q1"]),
+            PauliX("q0"),
+        ]
+
     def test_nested_modifiers(self):
         # parse the QASM program
         ast = parse(
diff --git a/tests/test_qnode.py b/tests/test_qnode.py
index 06fe0449b..682454d80 100644
--- a/tests/test_qnode.py
+++ b/tests/test_qnode.py
@@ -2231,6 +2231,20 @@ class TestSetShots:
         # Verify that the set_shots value (50) was used, not the parameter value (25)
         assert len(result) == 50
 
+    def test_set_shots_direct_decorator(self):
+        """Test set_shots with partial decorator syntax."""
+        dev = qml.device("default.qubit", wires=1, shots=10)
+
+        @set_shots(shots=50)
+        @qml.qnode(dev)
+        def circuit():
+            qml.RX(1.0, wires=0)
+            return qml.sample(qml.PauliZ(0))
+
+        assert circuit._shots == qml.measurements.Shots(50)
+        result = circuit()
+        assert len(result) == 50
+
     def test_set_shots_partial_decorator(self):
         """Test set_shots with partial decorator syntax."""
         dev = qml.device("default.qubit", wires=1, shots=10)
@@ -2452,3 +2466,41 @@ class TestSetShots:
 
         # Original circuit should be unchanged
         assert circuit._shots == qml.measurements.Shots(50)
+
+    def test_no_warning_if_shots_not_updated(self):
+        """Test that no warning is raised if set_shots is called but the shots value is unchanged."""
+        dev = qml.device("default.qubit")
+
+        @qml.qnode(dev)
+        def circuit():
+            return qml.sample(qml.PauliZ(0))
+
+        # No warning should be raised when calling with the same shots value
+        with warnings.catch_warnings(record=True) as record:
+            warnings.simplefilter("always")
+            result = circuit.update(diff_method="parameter-shift")(shots=50)
+        # Filter for targeted warnings (by type and/or message)
+        targeted = [
+            w
+            for w in record
+            if issubclass(w.category, UserWarning)
+            and "Both 'shots=' parameter and 'set_shots' transform are specified." in str(w.message)
+        ]
+        assert len(targeted) == 0
+        assert len(result) == 50
+
+    def test_no_warning_if_shots_not_updated_set_shots(self):
+        """Test that no warning is raised if set_shots is called but the shots value is unchanged."""
+        dev = qml.device("default.qubit")
+
+        @partial(qml.set_shots, shots=100)
+        @qml.qnode(dev)
+        def circuit():
+            return qml.sample(qml.PauliZ(0))
+
+        # No warning should be raised when calling with the same shots value
+        with pytest.warns(
+            UserWarning, match="Both 'shots=' parameter and 'set_shots' transform are specified."
+        ):
+            result = circuit.update(diff_method="parameter-shift")(shots=50)
+        assert len(result) == 100