Repository: PennyLaneAI/pennylane
Pull request #7428 (2025-05-14 14:22:02)

Revert `cotengra` version pin after bug fix in 0.7.4 release
CI failures due to an internal error introduced in `cotengra` 0.7.3 release (see issue https://github.com/jcmgray/quimb/issues/300) have already been solved by a bug fix in version 0.7.4, including unit tests to avoid this issue to silently occur again in the future (see [here](https://github.com/jcmgray/cotengra/releases/tag/v0.7.4)).

Ready to unpin `cotengra` version for PennyLane `default.tensor` device (revert https://github.com/PennyLaneAI/pennylane/commit/b671d5e7a9ae9e015a36a029bbdac3346a724b6d).

[sc-91240]

github-actions[bot]: Hello. You may have forgotten to update the changelog!
Please edit [`doc/releases/changelog-dev.md`](/PennyLaneAI/pennylane/blob/master/doc/releases/changelog-dev.md) with:
* A one-to-two sentence description of the change. You may include a small working example for new features.
* A link back to this PR.
* Your name (or GitHub username) in the contributors section.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7428?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.69%. Comparing base [(`34dfd68`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/34dfd6884eb7e89e6de8443421302e9c5101cb0c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`948f88b`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/948f88b871a01e2756df27b464ffdc2c4873b175?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).
> Report is 4 commits behind head on master.

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


```diff
@@           Coverage Diff           @@
##           master    #7428   +/-   ##
=======================================
  Coverage   99.69%   99.69%           
=======================================
  Files         529      529           
  Lines       50676    50676           
=======================================
  Hits        50521    50521           
  Misses        155      155           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/7428?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>
JerryChen97: Hmm I wonder why would a stochastic failure block other runners...

Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 81a990943..ff9d9cdd2 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -4,6 +4,36 @@
 
 <h3>New features since last release</h3>
 
+* A new function called `qml.to_openqasm` has been added, which allows for converting PennyLane circuits to OpenQASM 2.0 programs.
+  [(#7393)](https://github.com/PennyLaneAI/pennylane/pull/7393)
+
+  Consider this simple circuit in PennyLane:
+  ```python
+  dev = qml.device("default.qubit", wires=2, shots=100)
+
+  @qml.qnode(dev)
+  def circuit(theta, phi):
+      qml.RX(theta, wires=0)
+      qml.CNOT(wires=[0,1])
+      qml.RZ(phi, wires=1)
+      return qml.sample()
+  ```
+
+  This can be easily converted to OpenQASM 2.0 with `qml.to_openqasm`:
+  ```pycon
+  >>> openqasm_circ = qml.to_openqasm(circuit)(1.2, 0.9)
+  >>> print(openqasm_circ)
+  OPENQASM 2.0;
+  include "qelib1.inc";
+  qreg q[2];
+  creg c[2];
+  rx(1.2) q[0];
+  cx q[0],q[1];
+  rz(0.9) q[1];
+  measure q[0] -> c[0];
+  measure q[1] -> c[1];
+  ```
+
 * A new template called :class:`~.SelectPauliRot` that applies a sequence of uniformly controlled rotations to a target qubit 
   is now available. This operator appears frequently in unitary decomposition and block encoding techniques. 
   [(#7206)](https://github.com/PennyLaneAI/pennylane/pull/7206)
@@ -405,6 +435,7 @@ Astral Cai,
 Yushao Chen,
 Lillian Frederiksen,
 Pietropaolo Frisoni,
+Simone Gasperini,
 Korbinian Kottmann,
 Christina Lee,
 Anton Naim Ibrahim,
diff --git a/pennylane/__init__.py b/pennylane/__init__.py
index 4b6914c56..4c47482fe 100644
--- a/pennylane/__init__.py
+++ b/pennylane/__init__.py
@@ -78,6 +78,7 @@ from pennylane.registers import registers
 from pennylane.io import (
     from_pyquil,
     from_qasm,
+    to_openqasm,
     from_qiskit,
     from_qiskit_noise,
     from_qiskit_op,
diff --git a/pennylane/io/__init__.py b/pennylane/io/__init__.py
index 7ec133cb2..e91cac413 100644
--- a/pennylane/io/__init__.py
+++ b/pennylane/io/__init__.py
@@ -18,6 +18,7 @@ This module contains functions to import circuits and objects from external fram
 from .io import (
     from_pyquil,
     from_qasm,
+    to_openqasm,
     from_qiskit,
     from_qiskit_noise,
     from_qiskit_op,
diff --git a/pennylane/io/io.py b/pennylane/io/io.py
index d2678727e..81daed30e 100644
--- a/pennylane/io/io.py
+++ b/pennylane/io/io.py
@@ -16,8 +16,13 @@ This module contains functions to load circuits from other frameworks as
 PennyLane templates.
 """
 from collections import defaultdict
+from collections.abc import Callable
+from functools import wraps
 from importlib import metadata
 from sys import version_info
+from typing import Any, Optional
+
+from pennylane.wires import WiresLike
 
 # Error message to show when the PennyLane-Qiskit plugin is required but missing.
 _MISSING_QISKIT_PLUGIN_MESSAGE = (
@@ -613,6 +618,126 @@ def from_qasm(quantum_circuit: str, measurements=None):
     return plugin_converter(quantum_circuit, measurements=measurements)
 
 
+def to_openqasm(
+    qnode,
+    wires: Optional[WiresLike] = None,
+    rotations: bool = True,
+    measure_all: bool = True,
+    precision: Optional[int] = None,
+) -> Callable[[Any], str]:
+    """Convert a circuit to an OpenQASM 2.0 program.
+
+    .. note::
+      Terminal measurements are assumed to be performed on all qubits in the computational basis.
+      An optional ``rotations`` argument can be provided so that the output of the OpenQASM circuit
+      is diagonal in the eigenbasis of the quantum circuit's observables.
+      The measurement outputs can be restricted to only those specified in the circuit by setting ``measure_all=False``.
+
+    Args:
+        wires (Wires or None): the wires to use when serializing the circuit.
+            Default is ``None``, such that all device wires from the QNode are used for serialization.
+        rotations (bool): if ``True``, add gates that diagonalize the measured wires to the eigenbasis
+            of the circuit's observables. Default is ``True``.
+        measure_all (bool): if ``True``, add a computational basis measurement on all the qubits.
+            Default is ``True``.
+        precision (int or None): number of decimal digits to display for the parameters.
+
+    Returns:
+        str: OpenQASM 2.0 program corresponding to the circuit.
+
+    **Example**
+
+    The following QNode can be serialized to an OpenQASM 2.0 program:
+
+    .. code-block:: python
+
+        dev = qml.device("default.qubit", wires=2, shots=100)
+
+        @qml.qnode(dev)
+        def circuit(theta, phi):
+            qml.RX(theta, wires=0)
+            qml.CNOT(wires=[0,1])
+            qml.RZ(phi, wires=1)
+            return qml.sample()
+
+    >>> print(qml.to_openqasm(circuit)(1.2, 0.9))
+    OPENQASM 2.0;
+    include "qelib1.inc";
+    qreg q[2];
+    creg c[2];
+    rx(1.2) q[0];
+    cx q[0],q[1];
+    rz(0.9) q[1];
+    measure q[0] -> c[0];
+    measure q[1] -> c[1];
+
+    .. details::
+        :title: Usage Details
+
+        By default, the resulting OpenQASM code will have terminal measurements on all qubits, where all the measurements are performed in the computational basis.
+        However, if terminal measurements in the QNode act only on a subset of the qubits and ``measure_all=False``,
+        the OpenQASM code will include measurements on those specific qubits only.
+
+        .. code-block:: python
+
+            dev = qml.device("default.qubit", wires=2, shots=100)
+
+            @qml.qnode(dev)
+            def circuit():
+                qml.Hadamard(0)
+                qml.CNOT(wires=[0,1])
+                return qml.sample(wires=1)
+
+        >>> print(qml.to_openqasm(circuit, measure_all=False)())
+        OPENQASM 2.0;
+        include "qelib1.inc";
+        qreg q[2];
+        creg c[2];
+        h q[0];
+        cx q[0],q[1];
+        measure q[1] -> c[1];
+
+        If the QNode returns an expectation value of a given observable and ``rotations=True``, the OpenQASM 2.0 program will also
+        include the gates that diagonalize the measured wires such that they are in the eigenbasis of the measured observable.
+
+        .. code-block:: python
+
+            dev = qml.device("default.qubit", wires=2, shots=100)
+
+            @qml.qnode(dev)
+            def circuit():
+                qml.Hadamard(0)
+                qml.CNOT(wires=[0,1])
+                return qml.expval(qml.PauliX(0) @ qml.PauliY(1))
+
+        >>> print(qml.to_openqasm(circuit, rotations=True)())
+        OPENQASM 2.0;
+        include "qelib1.inc";
+        qreg q[2];
+        creg c[2];
+        h q[0];
+        cx q[0],q[1];
+        h q[0];
+        z q[1];
+        s q[1];
+        h q[1];
+        measure q[0] -> c[0];
+        measure q[1] -> c[1];
+    """
+
+    # pylint: disable=import-outside-toplevel
+    from pennylane.workflow import construct_tape
+
+    @wraps(qnode)
+    def wrapper(*args, **kwargs) -> str:
+        tape = construct_tape(qnode)(*args, **kwargs)
+        return tape.to_openqasm(
+            wires=wires, rotations=rotations, measure_all=measure_all, precision=precision
+        )
+
+    return wrapper
+
+
 def from_pyquil(pyquil_program):
     """Loads pyQuil Program objects by using the converter in the
     PennyLane-Rigetti plugin.


Test patch:
diff --git a/.github/workflows/interface-unit-tests.yml b/.github/workflows/interface-unit-tests.yml
index 2d1bf5d5a..e41528da9 100644
--- a/.github/workflows/interface-unit-tests.yml
+++ b/.github/workflows/interface-unit-tests.yml
@@ -492,7 +492,7 @@ jobs:
       pytest_additional_args: ${{ needs.warnings-as-errors-setup.outputs.pytest_warning_args }}
       pytest_xml_file_path: '${{ inputs.job_name_prefix }}external-libraries-tests (${{ matrix.python-version }})${{ inputs.job_name_suffix }}.xml'
       additional_pip_packages: |
-        pyzx matplotlib stim quimb mitiq ply optax scipy-openblas32>=0.3.26 qualtran xdsl cotengra==0.7.2
+        pyzx matplotlib stim quimb mitiq ply optax scipy-openblas32>=0.3.26 qualtran xdsl
         git+https://github.com/PennyLaneAI/pennylane-qiskit.git@master
         jax==0.5.3 jaxlib==0.5.3
         ${{ needs.default-dependency-versions.outputs.tensorflow-version }}
diff --git a/tests/io/test_io.py b/tests/io/test_io.py
index 1232c5b51..55f49ce92 100644
--- a/tests/io/test_io.py
+++ b/tests/io/test_io.py
@@ -14,8 +14,10 @@
 """
 Unit tests for the :mod:`pennylane.io` module.
 """
+from textwrap import dedent
 from unittest.mock import Mock
 
+import numpy as np
 import pytest
 
 import pennylane as qml
@@ -193,3 +195,115 @@ class TestLoad:
 
             if mock_plugin_converters[plugin_converter].called:
                 raise RuntimeError(f"The other plugin converter {plugin_converter} was called.")
+
+
+class TestToOpenQasm:
+    """Test the qml.to_openqasm function."""
+
+    dev = qml.device("default.qubit", wires=2, shots=100)
+
+    def test_basic_example(self):
+        """Test basic usage on simple circuit with parameters."""
+
+        @qml.qnode(self.dev)
+        def circuit(theta, phi):
+            qml.RX(theta, wires=0)
+            qml.CNOT(wires=[0, 1])
+            qml.RZ(phi, wires=1)
+            return qml.sample()
+
+        qasm = qml.to_openqasm(circuit)(1.2, 0.9)
+
+        expected = dedent(
+            """\
+            OPENQASM 2.0;
+            include "qelib1.inc";
+            qreg q[2];
+            creg c[2];
+            rx(1.2) q[0];
+            cx q[0],q[1];
+            rz(0.9) q[1];
+            measure q[0] -> c[0];
+            measure q[1] -> c[1];
+            """
+        )
+        assert qasm == expected
+
+    def test_measure_qubits_subset_only(self):
+        """Test OpenQASM program includes measurements only over the qubits subset specified in the QNode."""
+
+        @qml.qnode(self.dev)
+        def circuit():
+            qml.Hadamard(0)
+            qml.CNOT(wires=[0, 1])
+            return qml.sample(wires=1)
+
+        qasm = qml.to_openqasm(circuit, measure_all=False)()
+
+        expected = dedent(
+            """\
+            OPENQASM 2.0;
+            include "qelib1.inc";
+            qreg q[2];
+            creg c[2];
+            h q[0];
+            cx q[0],q[1];
+            measure q[1] -> c[1];
+            """
+        )
+        assert qasm == expected
+
+    def test_rotations_with_expval(self):
+        """Test OpenQASM program includes gates that make the measured observables diagonal in the computational basis."""
+
+        @qml.qnode(self.dev)
+        def circuit():
+            qml.Hadamard(0)
+            qml.CNOT(wires=[0, 1])
+            return qml.expval(qml.PauliX(0) @ qml.PauliY(1))
+
+        qasm = qml.to_openqasm(circuit, rotations=True)()
+
+        expected = dedent(
+            """\
+            OPENQASM 2.0;
+            include "qelib1.inc";
+            qreg q[2];
+            creg c[2];
+            h q[0];
+            cx q[0],q[1];
+            h q[0];
+            z q[1];
+            s q[1];
+            h q[1];
+            measure q[0] -> c[0];
+            measure q[1] -> c[1];
+            """
+        )
+        assert qasm == expected
+
+    def test_precision(self):
+        """Test OpenQASM program takes into account the desired numerical precision of the circuit's parameters."""
+
+        @qml.qnode(self.dev)
+        def circuit():
+            qml.RX(np.pi, wires=0)
+            qml.CNOT(wires=[0, 1])
+            return qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))
+
+        qasm = qml.to_openqasm(circuit, precision=4)()
+
+        expected = dedent(
+            """\
+            OPENQASM 2.0;
+            include "qelib1.inc";
+            qreg q[2];
+            creg c[2];
+            rx(3.142) q[0];
+            cx q[0],q[1];
+            measure q[0] -> c[0];
+            measure q[1] -> c[1];
+            """
+        )
+
+        assert qasm == expected