Repository: cda-tum/mqt-bench
Pull request #540: ♻️ refactored output module
Author: login: nquetschlich
Labels: name: python
description: Pull requests that update Python code
name: refactor
description: Anything related to refactoring the code base
Created: 2025-04-22T09:25:37Z | Merged: 2025-04-23T12:17:34Z

resolves #539 


Diff:
diff --git a/src/mqt/bench/benchmark_generation.py b/src/mqt/bench/benchmark_generation.py
index d87186673..c539e60bc 100644
--- a/src/mqt/bench/benchmark_generation.py
+++ b/src/mqt/bench/benchmark_generation.py
@@ -2,11 +2,9 @@
 
 from __future__ import annotations
 
-import signal
-import sys
+from importlib import import_module
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, Literal, TypedDict, overload
-from warnings import warn
+from typing import TYPE_CHECKING, Literal, TypedDict, overload
 
 from qiskit import QuantumCircuit, transpile
 
@@ -16,16 +14,13 @@
     get_device_by_name,
     get_provider_by_name,
 )
-from .utils import (
-    get_module_for_benchmark,
-    get_openqasm_gates,
-    get_supported_benchmarks,
-    get_supported_levels,
-    save_as_qasm,
+from .output import (
+    OutputFormat,
+    save_circuit,
 )
 
 if TYPE_CHECKING:  # pragma: no cover
-    from collections.abc import Callable
+    from types import ModuleType
 
     from .devices import Device, Provider
 
@@ -65,6 +60,39 @@ class CompilerSettings:
     qiskit: QiskitSettings | None = None
 
 
+def generate_filename(
+    benchmark_name: str,
+    level: str,
+    num_qubits: int | None,
+    provider_name: str | None = None,
+    device_name: str | None = None,
+    opt_level: int | None = None,
+) -> str:
+    """Generate a benchmark filename based on the abstraction level and context.
+
+    Arguments:
+        benchmark_name: The name of the benchmark
+        level: The abstraction level
+        num_qubits: Number of qubits in the benchmark circuit
+        provider_name: Optional name of the gate set provider (used for 'nativegates')
+        device_name: Optional name of the device (used for 'mapped')
+        opt_level: Optional optimization level (used for 'nativegates' and 'mapped')
+
+    Returns:
+        A string representing a filename (excluding extension) that encodes
+        all relevant metadata for reproducibility and clarity.
+    """
+    base = f"{benchmark_name}_{level}"
+
+    if level == "nativegates" and provider_name is not None and opt_level is not None:
+        return f"{base}_{provider_name}_opt{opt_level}_{num_qubits}"
+
+    if level == "mapped" and device_name is not None and opt_level is not None:
+        return f"{base}_{device_name}_opt{opt_level}_{num_qubits}"
+
+    return f"{base}_{num_qubits}"
+
+
 def get_alg_level(
     qc: QuantumCircuit,
     num_qubits: int | None,
@@ -72,7 +100,7 @@ def get_alg_level(
     return_qc: bool = False,
     target_directory: str = "./",
     target_filename: str = "",
-    qasm_format: str = "qasm3",
+    output_format: OutputFormat = OutputFormat.QASM3,
 ) -> bool | QuantumCircuit:
     """Handles the creation of the benchmark on the algorithm level.
 
@@ -83,8 +111,7 @@ def get_alg_level(
         return_qc: flag if the actual circuit shall be returned
         target_directory: alternative directory to the default one to store the created circuit
         target_filename: alternative filename to the default one
-        qasm_format: qasm format (qasm2 or qasm3)
-
+        output_format: one of supported formats, as defined in `OutputFormat`
 
     Returns:
         if return_qc == True: quantum circuit object
@@ -93,17 +120,22 @@ def get_alg_level(
     if return_qc:
         return qc
 
-    if qasm_format == "qasm2":
-        msg = "'qasm2' is not supported for the algorithm level, please use 'qasm3' instead."
+    if output_format == OutputFormat.QASM2:
+        msg = "'qasm2' is not supported for the algorithm level; please use e.g. 'qasm3' or 'qpy'."
         raise ValueError(msg)
-    filename_alg = target_filename or qc.name + "_alg_" + str(num_qubits) + "_" + qasm_format
 
-    path = Path(target_directory, filename_alg + ".qasm")
+    filename_alg = target_filename or generate_filename(benchmark_name=qc.name, level="alg", num_qubits=num_qubits)
+    path = Path(target_directory, f"{filename_alg}.{output_format.extension()}")
 
     if file_precheck and path.is_file():
         return True
 
-    return save_as_qasm(qc=qc, filename=filename_alg, qasm_format="qasm3", target_directory=target_directory)
+    return save_circuit(
+        qc=qc,
+        filename=filename_alg,
+        output_format=output_format,
+        target_directory=target_directory,
+    )
 
 
 @overload
@@ -114,7 +146,7 @@ def get_indep_level(
     return_qc: Literal[True],
     target_directory: str = "./",
     target_filename: str = "",
-    qasm_format: str = "qasm3",
+    output_format: OutputFormat = OutputFormat.QASM3,
 ) -> QuantumCircuit: ...
 
 
@@ -126,7 +158,7 @@ def get_indep_level(
     return_qc: Literal[False],
     target_directory: str = "./",
     target_filename: str = "",
-    qasm_format: str = "qasm3",
+    output_format: OutputFormat = OutputFormat.QASM3,
 ) -> bool: ...
 
 
@@ -137,7 +169,7 @@ def get_indep_level(
     return_qc: bool = False,
     target_directory: str = "./",
     target_filename: str = "",
-    qasm_format: str = "qasm3",
+    output_format: OutputFormat = OutputFormat.QASM3,
 ) -> bool | QuantumCircuit:
     """Handles the creation of the benchmark on the target-independent level.
 
@@ -148,26 +180,34 @@ def get_indep_level(
         return_qc: flag if the actual circuit shall be returned
         target_directory: alternative directory to the default one to store the created circuit
         target_filename: alternative filename to the default one
-        qasm_format: qasm format (qasm2 or qasm3)
-
+        output_format: one of supported formats, as defined in `OutputFormat`
 
     Returns:
         if return_qc == True: quantum circuit object
         else: True/False indicating whether the function call was successful or not
     """
-    filename_indep = target_filename or qc.name + "_indep_" + str(num_qubits) + "_" + qasm_format
+    filename_indep = target_filename or generate_filename(benchmark_name=qc.name, level="indep", num_qubits=num_qubits)
+    path = Path(target_directory, f"{filename_indep}.{output_format.extension()}")
 
-    path = Path(target_directory, filename_indep + ".qasm")
     if file_precheck and path.is_file():
         return True
+
     openqasm_gates = get_openqasm_gates()
-    target_independent = transpile(qc, basis_gates=openqasm_gates, optimization_level=1, seed_transpiler=10)
+    target_independent = transpile(
+        qc,
+        basis_gates=openqasm_gates,
+        optimization_level=1,
+        seed_transpiler=10,
+    )
 
     if return_qc:
         return target_independent
 
-    return save_as_qasm(
-        qc=target_independent, filename=filename_indep, qasm_format=qasm_format, target_directory=target_directory
+    return save_circuit(
+        qc=target_independent,
+        filename=filename_indep,
+        output_format=output_format,
+        target_directory=target_directory,
     )
 
 
@@ -181,7 +221,7 @@ def get_native_gates_level(
     return_qc: Literal[True],
     target_directory: str = "./",
     target_filename: str = "",
-    qasm_format: str = "qasm3",
+    output_format: OutputFormat = OutputFormat.QASM3,
 ) -> QuantumCircuit: ...
 
 
@@ -195,7 +235,7 @@ def get_native_gates_level(
     return_qc: Literal[False],
     target_directory: str = "./",
     target_filename: str = "",
-    qasm_format: str = "qasm3",
+    output_format: OutputFormat = OutputFormat.QASM3,
 ) -> bool: ...
 
 
@@ -208,7 +248,7 @@ def get_native_gates_level(
     return_qc: bool = False,
     target_directory: str = "./",
     target_filename: str = "",
-    qasm_format: str = "qasm3",
+    output_format: OutputFormat = OutputFormat.QASM3,
 ) -> bool | QuantumCircuit:
     """Handles the creation of the benchmark on the target-dependent native gates level.
 
@@ -221,34 +261,39 @@ def get_native_gates_level(
         return_qc: flag if the actual circuit shall be returned
         target_directory: alternative directory to the default one to store the created circuit
         target_filename: alternative filename to the default one
-        qasm_format: qasm format (qasm2 or qasm3)
+        output_format: one of supported formats, as defined in `OutputFormat`
 
     Returns:
         if return_qc == True: quantum circuit object
         else: True/False indicating whether the function call was successful or not
     """
-    if not target_filename:
-        filename_native = (
-            qc.name + "_nativegates_" + provider.provider_name + "_opt" + str(opt_level) + "_" + str(num_qubits)
-        )
-    else:
-        filename_native = target_filename
+    filename_native = target_filename or generate_filename(
+        benchmark_name=qc.name,
+        level="native",
+        num_qubits=num_qubits,
+        provider_name=provider.provider_name,
+        opt_level=opt_level,
+    )
+    path = Path(target_directory, f"{filename_native}.{output_format.extension()}")
 
-    path = Path(target_directory, filename_native + ".qasm")
     if file_precheck and path.is_file():
         return True
 
     gate_set = provider.get_native_gates()
-    compiled_without_architecture = transpile(
-        qc, basis_gates=gate_set, optimization_level=opt_level, seed_transpiler=10
+    compiled = transpile(
+        qc,
+        basis_gates=gate_set,
+        optimization_level=opt_level,
+        seed_transpiler=10,
     )
+
     if return_qc:
-        return compiled_without_architecture
+        return compiled
 
-    return save_as_qasm(
-        qc=compiled_without_architecture,
+    return save_circuit(
+        qc=compiled,
         filename=filename_native,
-        qasm_format=qasm_format,
+        output_format=output_format,
         gate_set=gate_set,
         target_directory=target_directory,
     )
@@ -264,7 +309,7 @@ def get_mapped_level(
     return_qc: Literal[True],
     target_directory: str = "./",
     target_filename: str = "",
-    qasm_format: str = "qasm3",
+    output_format: OutputFormat = OutputFormat.QASM3,
 ) -> QuantumCircuit: ...
 
 
@@ -278,7 +323,7 @@ def get_mapped_level(
     return_qc: Literal[False],
     target_directory: str = "./",
     target_filename: str = "",
-    qasm_format: str = "qasm3",
+    output_format: OutputFormat = OutputFormat.QASM3,
 ) -> bool: ...
 
 
@@ -291,7 +336,7 @@ def get_mapped_level(
     return_qc: bool = False,
     target_directory: str = "./",
     target_filename: str = "",
-    qasm_format: str = "qasm3",
+    output_format: OutputFormat = OutputFormat.QASM3,
 ) -> bool | QuantumCircuit:
     """Handles the creation of the benchmark on the target-dependent mapped level.
 
@@ -304,38 +349,37 @@ def get_mapped_level(
         return_qc: flag if the actual circuit shall be returned
         target_directory: alternative directory to the default one to store the created circuit
         target_filename: alternative filename to the default one
-        qasm_format: qasm format (qasm2 or qasm3)
+        output_format: one of supported formats, as defined in `OutputFormat`
 
     Returns:
         if return_qc == True: quantum circuit object
         else: True/False indicating whether the function call was successful or not
     """
-    if not target_filename:
-        filename_mapped = qc.name + "_mapped_" + device.name + "_opt" + str(opt_level) + "_" + str(num_qubits)
-    else:
-        filename_mapped = target_filename
+    filename_mapped = target_filename or generate_filename(
+        benchmark_name=qc.name, level="mapped", num_qubits=num_qubits, device_name=device.name, opt_level=opt_level
+    )
+    path = Path(target_directory, f"{filename_mapped}.{output_format.extension()}")
 
-    path = Path(target_directory, filename_mapped + ".qasm")
     if file_precheck and path.is_file():
         return True
 
     c_map = device.coupling_map
-    compiled_with_architecture = transpile(
+    compiled = transpile(
         qc,
         optimization_level=opt_level,
         basis_gates=device.basis_gates,
         coupling_map=c_map,
         seed_transpiler=10,
     )
+
     if return_qc:
-        return compiled_with_architecture
+        return compiled
 
-    return save_as_qasm(
-        qc=compiled_with_architecture,
+    return save_circuit(
+        qc=compiled,
         filename=filename_mapped,
-        qasm_format=qasm_format,
+        output_format=output_format,
         gate_set=device.basis_gates,
-        mapped=True,
         c_map=c_map,
         target_directory=target_directory,
     )
@@ -450,50 +494,87 @@ def get_benchmark(
     raise ValueError(msg)
 
 
-def timeout_watcher(
-    func: Callable[..., bool | QuantumCircuit],
-    timeout: int,
-    args: list[Any] | int | tuple[int, str] | str,
-) -> bool | QuantumCircuit:
-    """Function to handle timeouts for the benchmark generation."""
-    if sys.platform == "win32":
-        warn("Timeout is not supported on Windows.", category=RuntimeWarning, stacklevel=2)
-        return func(*args) if isinstance(args, (tuple, list)) else func(args)
-
-    class TimeoutExceptionError(Exception):  # Custom exception class
-        """Custom exception class for timeout."""
-
-    def timeout_handler(_signum: int, _frame: Any) -> None:  # noqa: ANN401
-        """Function to handle the timeout."""
-        raise TimeoutExceptionError
-
-    # Change the behavior of SIGALRM
-    signal.signal(signal.SIGALRM, timeout_handler)
-    signal.alarm(timeout)
-    try:
-        res = func(*args) if isinstance(args, (tuple, list)) else func(args)
-    except TimeoutExceptionError:
-        print(
-            "Calculation/Generation exceeded timeout limit for ",
-            func.__name__,
-            func.__module__.split(".")[-1],
-        )
-        if isinstance(args, list) and isinstance(args[0], QuantumCircuit):
-            print(args[0].name, args[1:])
-        else:
-            print(args)
-        return False
-    except Exception as e:
-        print(
-            "Exception: ",
-            e,
-            func.__name__,
-            func.__module__.split(".")[-1],
-            args,
-        )
-        return False
-    else:
-        # Reset the alarm
-        signal.alarm(0)
-
-    return res
+def get_supported_benchmarks() -> list[str]:
+    """Returns a list of all supported benchmarks."""
+    return [
+        "ae",
+        "bv",
+        "dj",
+        "grover-noancilla",
+        "grover-v-chain",
+        "ghz",
+        "graphstate",
+        "qaoa",
+        "qft",
+        "qftentangled",
+        "qnn",
+        "qpeexact",
+        "qpeinexact",
+        "qwalk-noancilla",
+        "qwalk-v-chain",
+        "randomcircuit",
+        "vqerealamprandom",
+        "vqesu2random",
+        "vqetwolocalrandom",
+        "wstate",
+        "shor",
+    ]
+
+
+def get_supported_levels() -> list[str | int]:
+    """Returns a list of all supported benchmark levels."""
+    return ["alg", "indep", "nativegates", "mapped", 0, 1, 2, 3]
+
+
+def get_openqasm_gates() -> list[str]:
+    """Returns a list of all quantum gates within the openQASM 2.0 standard header."""
+    # according to https://github.com/Qiskit/qiskit-terra/blob/main/qiskit/qasm/libs/qelib1.inc
+    return [
+        "u3",
+        "u2",
+        "u1",
+        "cx",
+        "id",
+        "u0",
+        "u",
+        "p",
+        "x",
+        "y",
+        "z",
+        "h",
+        "s",
+        "sdg",
+        "t",
+        "tdg",
+        "rx",
+        "ry",
+        "rz",
+        "sx",
+        "sxdg",
+        "cz",
+        "cy",
+        "swap",
+        "ch",
+        "ccx",
+        "cswap",
+        "crx",
+        "cry",
+        "crz",
+        "cu1",
+        "cp",
+        "cu3",
+        "csx",
+        "cu",
+        "rxx",
+        "rzz",
+        "rccx",
+        "rc3x",
+        "c3x",
+        "c3sqrtx",
+        "c4x",
+    ]
+
+
+def get_module_for_benchmark(benchmark_name: str) -> ModuleType:
+    """Returns the module for a specific benchmark."""
+    return import_module("mqt.bench.benchmarks." + benchmark_name)
diff --git a/src/mqt/bench/benchmarks/randomcircuit.py b/src/mqt/bench/benchmarks/randomcircuit.py
index 7eac2acd5..d9cd26952 100644
--- a/src/mqt/bench/benchmarks/randomcircuit.py
+++ b/src/mqt/bench/benchmarks/randomcircuit.py
@@ -5,7 +5,7 @@
 from qiskit import QuantumCircuit, transpile
 from qiskit.circuit.random import random_circuit
 
-from mqt.bench import utils
+from mqt.bench.benchmark_generation import get_openqasm_gates
 
 
 def create_circuit(num_qubits: int) -> QuantumCircuit:
@@ -18,7 +18,7 @@ def create_circuit(num_qubits: int) -> QuantumCircuit:
         QuantumCircuit: a random quantum circuit twice as deep as wide
     """
     qc = random_circuit(num_qubits, num_qubits * 2, measure=False, seed=10)
-    gates = list(set(utils.get_openqasm_gates()) - {"rccx", "csx", "cu"})
+    gates = list(set(get_openqasm_gates()) - {"rccx", "csx", "cu"})
     qc = transpile(
         qc,
         basis_gates=gates,
diff --git a/src/mqt/bench/cli.py b/src/mqt/bench/cli.py
index 0dbfc487b..677e46711 100644
--- a/src/mqt/bench/cli.py
+++ b/src/mqt/bench/cli.py
@@ -3,12 +3,13 @@
 from __future__ import annotations
 
 import argparse
+import sys
 from importlib import metadata
-from typing import cast
-
-from qiskit.qasm2 import dumps as qiskit_circuit_to_str
+from pathlib import Path
 
 from . import CompilerSettings, QiskitSettings, get_benchmark
+from .benchmark_generation import generate_filename
+from .output import OutputFormat, save_circuit, write_circuit
 
 
 class CustomArgumentParser(argparse.ArgumentParser):
@@ -23,56 +24,126 @@ def format_help(self) -> str:
         return help_message + version_info
 
 
-def main() -> None:
-    """Invoke :func:`get_benchmark` exactly once with the specified arguments.
+def parse_benchmark_name_and_instance(algorithm: str) -> tuple[str, str | None]:
+    """Parse an algorithm name like "shor_xlarge" into a benchmark and instance name."""
+    if algorithm.startswith("shor_"):
+        parts = algorithm.split("_", 1)
+        return parts[0], parts[1]
+    return algorithm, None
+
 
-    The resulting QASM string is printed to the standard output stream;
-    no other output is generated.
-    """
+def main() -> None:
+    """Generate a single benchmark and output in specified format."""
     parser = CustomArgumentParser(description="Generate a single benchmark")
     parser.add_argument(
         "--level",
         type=str,
-        help='Level to generate benchmarks for ("alg", "indep", "nativegates" or "mapped")',
+        choices=["alg", "indep", "nativegates", "mapped"],
+        help='Level to generate benchmarks for ("alg", "indep", "nativegates" or "mapped").',
+        required=True,
+    )
+    parser.add_argument(
+        "--algorithm",
+        type=str,
+        help="Name of the benchmark (e.g., 'grover-v-chain', 'shor_xsmall').",
         required=True,
     )
-    parser.add_argument("--algorithm", type=str, help="Name of the benchmark", required=True)
-    parser.add_argument("--num-qubits", type=int, help="Number of Qubits", required=True)
-    parser.add_argument("--qiskit-optimization-level", type=int, help="Qiskit compiler optimization level")
-    parser.add_argument("--native-gate-set", type=str, help="Name of the provider")
-    parser.add_argument("--device", type=str, help="Name of the device")
+    parser.add_argument(
+        "--num-qubits",
+        type=int,
+        help="Number of qubits for the benchmark.",
+        required=True,
+    )
+    parser.add_argument(
+        "--qiskit-optimization-level",
+        type=int,
+        help="Qiskit compiler optimization level (0-3).",
+    )
+    parser.add_argument(
+        "--native-gate-set",
+        type=str,
+        help="Provider name for native gate set (e.g., 'ibm', 'rigetti').",
+    )
+    parser.add_argument(
+        "--device",
+        type=str,
+        help="Device name for mapping stage (e.g., 'ibm_washington').",
+    )
+    parser.add_argument(
+        "--output-format",
+        type=str,
+        choices=[fmt.value for fmt in OutputFormat],
+        default=OutputFormat.QASM3.value,
+        help=f"Output format. Possible values: {[fmt.value for fmt in OutputFormat]}.",
+    )
+    parser.add_argument(
+        "--target-directory",
+        type=str,
+        default=".",
+        help="Directory to save the output file (only used for 'qpy' or if --save is specified).",
+    )
+    parser.add_argument(
+        "--save",
+        action="store_true",
+        help="If set, save the output to a file instead of printing to stdout (e.g. for 'qpy', which is not available as text).",
+    )
+
     args = parser.parse_args()
 
+    # Build Qiskit settings
     qiskit_settings = QiskitSettings()
     if args.qiskit_optimization_level is not None:
         qiskit_settings = QiskitSettings(args.qiskit_optimization_level)
 
-    # Note: Assertions about argument validity are in get_benchmark()
-
-    # Temporary workaround to "get things working" with benchmark instances.
-    # This special treatment should be removed as soon as instance handling has been refactored in get_benchmark().
+    # Parse algorithm and optional instance
     benchmark_name, benchmark_instance = parse_benchmark_name_and_instance(args.algorithm)
 
-    result = get_benchmark(
+    # Generate circuit
+    circuit = get_benchmark(
         benchmark_name=benchmark_name,
         benchmark_instance_name=benchmark_instance,
         level=args.level,
         circuit_size=args.num_qubits,
-        compiler_settings=CompilerSettings(
-            qiskit=qiskit_settings,
-        ),
+        compiler_settings=CompilerSettings(qiskit=qiskit_settings),
+        provider_name=args.native_gate_set or "ibm",
+        device_name=args.device or "ibm_washington",
+    )
+
+    try:
+        fmt = OutputFormat(args.output_format)
+    except ValueError:
+        msg = f"Unknown output format: {args.output_format}"
+        raise ValueError(msg) from None
+
+    # For QASM outputs, serialize and print
+    if fmt in (OutputFormat.QASM2, OutputFormat.QASM3) and not args.save:
+        write_circuit(circuit, sys.stdout, fmt)
+        return
+
+    # Otherwise, save to file
+    filename = generate_filename(
+        benchmark_name=benchmark_name,
+        level=args.level,
+        num_qubits=args.num_qubits,
         provider_name=args.native_gate_set,
         device_name=args.device,
+        opt_level=args.qiskit_optimization_level,
+    )
+    success = save_circuit(
+        qc=circuit,
+        filename=filename,
+        output_format=fmt,
+        target_directory=args.target_directory,
     )
-    print(qiskit_circuit_to_str(result))
-    return
+    if not success:
+        sys.exit(1)
 
+    # Optionally, inform user of file location if saving
+    if args.save or fmt == OutputFormat.QPY:
+        file_ext = fmt.extension()
+        path = Path(args.target_directory) / f"{filename}.{file_ext}"
+        print(path)
 
-def parse_benchmark_name_and_instance(algorithm: str) -> tuple[str, str | None]:
-    """Parse an algorithm name like "shor_xlarge" into a benchmark and instance name as expected by :func:`get_benchmark`."""
-    if algorithm.startswith("shor_"):
-        as_list = algorithm.split("_", 2)
-        assert len(as_list) == 2
-        return cast("tuple[str, str]", tuple(as_list))
 
-    return algorithm, None
+if __name__ == "__main__":
+    main()
diff --git a/src/mqt/bench/output.py b/src/mqt/bench/output.py
new file mode 100644
index 000000000..6035a6a8f
--- /dev/null
+++ b/src/mqt/bench/output.py
@@ -0,0 +1,211 @@
+"""Output functions for the MQT Bench module."""
+
+from __future__ import annotations
+
+from datetime import date
+from enum import Enum
+from importlib import metadata
+from io import TextIOBase
+from pathlib import Path
+from typing import TYPE_CHECKING, TextIO, overload
+
+from qiskit import QuantumCircuit
+from qiskit import __version__ as __qiskit_version__
+from qiskit.qasm2 import dump as dump2
+from qiskit.qasm3 import dump as dump3
+from qiskit.qpy import dump as dump_qpy
+
+if TYPE_CHECKING:  # pragma: no cover
+    from typing import BinaryIO
+
+
+class OutputFormat(str, Enum):
+    """Enumeration of supported output formats for circuit export."""
+
+    QASM2 = "qasm2"
+    QASM3 = "qasm3"
+    QPY = "qpy"
+
+    def extension(self) -> str:
+        """Return the canonical filename extension for this format."""
+        return "qasm" if self in (OutputFormat.QASM2, OutputFormat.QASM3) else self.value
+
+
+class MQTBenchExporterError(Exception):
+    """Custom exception for errors arising during MQT Bench exporting operations."""
+
+
+def _attach_metadata(qc: QuantumCircuit, header: str) -> QuantumCircuit:
+    """Return *a shallow copy* whose ``metadata`` carries the MQT-Bench header."""
+    clone = qc.copy()
+    clone.metadata = (clone.metadata or {}) | {"mqt_bench": header}
+    return clone
+
+
+def generate_header(
+    fmt: OutputFormat,
+    gate_set: list[str] | None = None,
+    c_map: list[list[int]] | None = None,
+) -> str:
+    """Generate a standardized header for MQT Bench outputs.
+
+    Arguments:
+        fmt: The chosen output format enum member
+        gate_set: Optional list of gate names used in the circuit
+        c_map: Optional coupling map of the hardware layout
+
+    Returns:
+        A string containing the formatted header.
+    """
+    try:
+        version = metadata.version("mqt.bench")
+    except Exception as exc:
+        msg = (
+            "The Python package `mqt.bench` is not installed in the current "
+            "environment. Install it with\n\n"
+            "    pip install mqt.bench\n\n"
+            f"and try again. (Original error: {exc})"
+        )
+        raise MQTBenchExporterError(msg) from None
+
+    lines: list[str] = []
+    lines.extend((
+        f"// Benchmark created by MQT Bench on {date.today()}",
+        "// For more info: https://www.cda.cit.tum.de/mqtbench/",
+        f"// MQT Bench version: {version}",
+        f"// Qiskit version: {__qiskit_version__}",
+        f"// Output format: {fmt.value}",
+    ))
+
+    if gate_set:
+        lines.append(f"// Used gate set: {gate_set}")
+    if c_map:
+        lines.append(f"// Coupling map: {c_map}")
+
+    return "\n".join(lines) + "\n\n"
+
+
+@overload
+def write_circuit(
+    qc: QuantumCircuit,
+    destination: Path,
+    fmt: OutputFormat = OutputFormat.QASM3,
+    gate_set: list[str] | None = None,
+    c_map: list[list[int]] | None = None,
+) -> None:  # pragma: no cover - typing overload only
+    ...
+
+
+@overload
+def write_circuit(
+    qc: QuantumCircuit,
+    destination: TextIO | BinaryIO,
+    fmt: OutputFormat = OutputFormat.QASM3,
+    gate_set: list[str] | None = None,
+    c_map: list[list[int]] | None = None,
+) -> None:  # pragma: no cover - typing overload only
+    ...
+
+
+def write_circuit(
+    qc: QuantumCircuit,
+    destination: Path | TextIO | BinaryIO,
+    fmt: OutputFormat = OutputFormat.QASM3,
+    gate_set: list[str] | None = None,
+    c_map: list[list[int]] | None = None,
+) -> None:
+    """Write the given quantum circuit to disk in the specified format, preceded by an MQT Bench header.
+
+    Arguments:
+        qc: The QuantumCircuit to export
+        destination: Destination file path or stream (including extension)
+        fmt: Desired output format
+        gate_set: Optional gate set list
+        c_map: Optional coupling map
+
+    Raises:
+        MQTBenchExporterError: On unsupported format or I/O errors.
+    """
+    header = generate_header(fmt, gate_set, c_map)
+
+    if not isinstance(destination, Path):
+        is_text = isinstance(destination, TextIOBase)
+
+        if fmt in (OutputFormat.QASM2, OutputFormat.QASM3):
+            if not is_text:
+                msg = "QASM output requires a *text* stream."
+                raise MQTBenchExporterError(msg)
+            try:
+                assert isinstance(destination, TextIOBase)
+                destination.write(header)
+                (dump2 if fmt is OutputFormat.QASM2 else dump3)(qc, destination)
+            except Exception as exc:  # pragma: no cover - unforeseen I/O
+                msg = f"Failed to write QASM stream. (Original error: {exc})"
+                raise MQTBenchExporterError(msg) from None
+            return
+
+        if fmt is OutputFormat.QPY:
+            if is_text:
+                msg = "QPY output requires a *binary* stream."
+                raise MQTBenchExporterError(msg)
+            try:
+                dump_qpy(_attach_metadata(qc, header), destination)
+            except Exception as exc:
+                msg = f"Failed to write QPY stream. (Original error: {exc})"
+                raise MQTBenchExporterError(msg) from None
+            return
+
+        msg = f"Unsupported output format {fmt}. Supported formats are {[m.value for m in OutputFormat]}."
+        raise MQTBenchExporterError(msg)
+
+    if fmt in (OutputFormat.QASM2, OutputFormat.QASM3):
+        try:
+            with destination.open("w", encoding="utf-8") as f:
+                f.write(header)
+                (dump2 if fmt is OutputFormat.QASM2 else dump3)(qc, f)
+        except Exception as exc:
+            msg = f"Failed to write {fmt.value.upper()} file to {destination}. (Original error: {exc})"
+            raise MQTBenchExporterError(msg) from None
+
+    elif fmt is OutputFormat.QPY:
+        try:
+            with destination.open("wb") as f:
+                dump_qpy(_attach_metadata(qc, header), f)
+        except Exception as exc:
+            msg = f"Failed to write QPY file to {destination}. (Original error: {exc})"
+            raise MQTBenchExporterError(msg) from None
+
+    else:
+        msg = f"Unsupported output format {fmt}. Supported formats are {[m.value for m in OutputFormat]}."
+        raise MQTBenchExporterError(msg)
+
+
+def save_circuit(
+    qc: QuantumCircuit,
+    filename: str,
+    output_format: OutputFormat = OutputFormat.QASM3,
+    gate_set: list[str] | None = None,
+    c_map: list[list[int]] | None = None,
+    target_directory: str = "",
+) -> bool:
+    """Public API to save a quantum circuit in various formats with MQT Bench header.
+
+    Arguments:
+        qc: Circuit to export
+        filename: Base filename without extension
+        output_format: One of the supported format values, as defined in `OutputFormat`
+        gate_set: Optional list of gate names
+        c_map: Optional coupling map
+        target_directory: Directory to place the output file
+
+    Returns:
+        True on success, False otherwise.
+    """
+    path = Path(target_directory) / f"{filename}.{output_format.extension()}"
+    try:
+        write_circuit(qc, path, output_format, gate_set, c_map)
+    except MQTBenchExporterError as e:
+        print(e)
+        return False
+
+    return True
diff --git a/src/mqt/bench/utils.py b/src/mqt/bench/utils.py
deleted file mode 100644
index b8ee973c5..000000000
--- a/src/mqt/bench/utils.py
+++ /dev/null
@@ -1,242 +0,0 @@
-"""Utility functions for the MQT Bench module."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from datetime import date
-from importlib import import_module, metadata, resources
-from pathlib import Path
-from typing import TYPE_CHECKING
-
-import networkx as nx
-import numpy as np
-from qiskit import QuantumCircuit
-from qiskit import __version__ as __qiskit_version__
-from qiskit.converters import circuit_to_dag
-from qiskit.qasm2 import dumps as dumps2
-from qiskit.qasm3 import dumps as dumps3
-
-if TYPE_CHECKING:  # pragma: no cover
-    from types import ModuleType
-
-
-@dataclass
-class SupermarqFeatures:
-    """Data class for the Supermarq features of a quantum circuit."""
-
-    program_communication: float
-    critical_depth: float
-    entanglement_ratio: float
-    parallelism: float
-    liveness: float
-
-
-def get_supported_benchmarks() -> list[str]:
-    """Returns a list of all supported benchmarks."""
-    return [
-        "ae",
-        "bv",
-        "dj",
-        "grover-noancilla",
-        "grover-v-chain",
-        "ghz",
-        "graphstate",
-        "qaoa",
-        "qft",
-        "qftentangled",
-        "qnn",
-        "qpeexact",
-        "qpeinexact",
-        "qwalk-noancilla",
-        "qwalk-v-chain",
-        "randomcircuit",
-        "vqerealamprandom",
-        "vqesu2random",
-        "vqetwolocalrandom",
-        "wstate",
-        "shor",
-    ]
-
-
-def get_supported_levels() -> list[str | int]:
-    """Returns a list of all supported benchmark levels."""
-    return ["alg", "indep", "nativegates", "mapped", 0, 1, 2, 3]
-
-
-def get_default_qasm_output_path() -> str:
-    """Returns the path where all .qasm files are stored."""
-    return str(resources.files("mqt.bench") / "viewer" / "static" / "files" / "qasm_output")
-
-
-def get_default_evaluation_output_path() -> str:
-    """Returns the path where all .qasm files are stored."""
-    return str(resources.files("mqt.bench") / "evaluation")
-
-
-def get_zip_folder_path() -> str:
-    """Returns the path where the zip file is stored."""
-    return str(resources.files("mqt.bench") / "viewer" / "static" / "files")
-
-
-def get_openqasm_gates() -> list[str]:
-    """Returns a list of all quantum gates within the openQASM 2.0 standard header."""
-    # according to https://github.com/Qiskit/qiskit-terra/blob/main/qiskit/qasm/libs/qelib1.inc
-    return [
-        "u3",
-        "u2",
-        "u1",
-        "cx",
-        "id",
-        "u0",
-        "u",
-        "p",
-        "x",
-        "y",
-        "z",
-        "h",
-        "s",
-        "sdg",
-        "t",
-        "tdg",
-        "rx",
-        "ry",
-        "rz",
-        "sx",
-        "sxdg",
-        "cz",
-        "cy",
-        "swap",
-        "ch",
-        "ccx",
-        "cswap",
-        "crx",
-        "cry",
-        "crz",
-        "cu1",
-        "cp",
-        "cu3",
-        "csx",
-        "cu",
-        "rxx",
-        "rzz",
-        "rccx",
-        "rc3x",
-        "c3x",
-        "c3sqrtx",
-        "c4x",
-    ]
-
-
-def save_as_qasm(
-    qc: QuantumCircuit,
-    filename: str,
-    qasm_format: str = "qasm2",
-    gate_set: list[str] | None = None,
-    mapped: bool = False,
-    c_map: list[list[int]] | None = None,
-    target_directory: str = "",
-) -> bool:
-    """Saves a quantum circuit as a qasm file.
-
-    Arguments:
-        qc: Quantum circuit to be stored as a string
-        filename: filename
-        qasm_format: qasm format (qasm2 or qasm3)
-        gate_set: set of used gates
-        mapped: boolean indicating whether the quantum circuit is mapped to a specific hardware layout
-        c_map: coupling map of used hardware layout
-        target_directory: directory where the qasm file is stored
-    """
-    if c_map is None:
-        c_map = []
-
-    file = Path(target_directory, filename + ".qasm")
-
-    if qasm_format == "qasm2":
-        qc_str = dumps2(qc)
-    elif qasm_format == "qasm3":
-        qc_str = dumps3(qc)
-    else:
-        msg = f"Unknown qasm format: {qasm_format}"
-        raise ValueError(msg)
-
-    try:
-        mqtbench_module_version = metadata.version("mqt.bench")
-    except Exception:
-        print("'mqt.bench' is most likely not installed. Please run 'pip install . or pip install mqt.bench'.")
-        return False
-
-    with file.open("w") as f:
-        f.write("// Benchmark was created by MQT Bench on " + str(date.today()) + "\n")
-        f.write("// For more information about MQT Bench, please visit https://www.cda.cit.tum.de/mqtbench/\n")
-        f.write("// MQT Bench version: " + mqtbench_module_version + "\n")
-        f.write("// Qiskit version: " + str(__qiskit_version__) + "\n")
-        if gate_set:
-            f.write("// Used Gate Set: " + str(gate_set) + "\n")
-        if mapped:
-            f.write("// Coupling List: " + str(c_map) + "\n")
-        f.write("\n")
-        f.write(qc_str)
-    f.close()
-
-    return True
-
-
-def calc_supermarq_features(
-    qc: QuantumCircuit,
-) -> SupermarqFeatures:
-    """Calculates the Supermarq features for a given quantum circuit. Code adapted from https://github.com/Infleqtion/client-superstaq/blob/91d947f8cc1d99f90dca58df5248d9016e4a5345/supermarq-benchmarks/supermarq/converters.py."""
-    num_qubits = qc.num_qubits
-    dag = circuit_to_dag(qc)
-    dag.remove_all_ops_named("barrier")
-
-    # Program communication = circuit's average qubit degree / degree of a complete graph.
-    graph = nx.Graph()
-    for op in dag.two_qubit_ops():
-        q1, q2 = op.qargs
-        graph.add_edge(qc.find_bit(q1).index, qc.find_bit(q2).index)
-    degree_sum = sum(graph.degree(n) for n in graph.nodes)
-    program_communication = degree_sum / (num_qubits * (num_qubits - 1)) if num_qubits > 1 else 0
-
-    # Liveness feature = sum of all entries in the liveness matrix / (num_qubits * depth).
-    activity_matrix = np.zeros((num_qubits, dag.depth()))
-    for i, layer in enumerate(dag.layers()):
-        for op in layer["partition"]:
-            for qubit in op:
-                activity_matrix[qc.find_bit(qubit).index, i] = 1
-    liveness = np.sum(activity_matrix) / (num_qubits * dag.depth()) if dag.depth() > 0 else 0
-
-    #  Parallelism feature = max((((# of gates / depth) -1) /(# of qubits -1)), 0).
-    parallelism = (
-        max(((len(dag.gate_nodes()) / dag.depth()) - 1) / (num_qubits - 1), 0)
-        if num_qubits > 1 and dag.depth() > 0
-        else 0
-    )
-
-    # Entanglement-ratio = ratio between # of 2-qubit gates and total number of gates in the circuit.
-    entanglement_ratio = len(dag.two_qubit_ops()) / len(dag.gate_nodes()) if len(dag.gate_nodes()) > 0 else 0
-
-    # Critical depth = # of 2-qubit gates along the critical path / total # of 2-qubit gates.
-    longest_paths = dag.count_ops_longest_path()
-    n_ed = sum(longest_paths[name] for name in {op.name for op in dag.two_qubit_ops()} if name in longest_paths)
-    n_e = len(dag.two_qubit_ops())
-    critical_depth = n_ed / n_e if n_e != 0 else 0
-
-    assert 0 <= program_communication <= 1
-    assert 0 <= critical_depth <= 1
-    assert 0 <= entanglement_ratio <= 1
-    assert 0 <= parallelism <= 1
-    assert 0 <= liveness <= 1
-
-    return SupermarqFeatures(
-        program_communication,
-        critical_depth,
-        entanglement_ratio,
-        parallelism,
-        liveness,
-    )
-
-
-def get_module_for_benchmark(benchmark_name: str) -> ModuleType:
-    """Returns the module for a specific benchmark."""
-    return import_module("mqt.bench.benchmarks." + benchmark_name)
diff --git a/tests/test_bench.py b/tests/test_bench.py
index cda2649a0..f11b92d30 100644
--- a/tests/test_bench.py
+++ b/tests/test_bench.py
@@ -2,27 +2,34 @@
 
 from __future__ import annotations
 
-import sys
+import builtins
+import io
+from datetime import date
+from importlib import metadata
 from pathlib import Path
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, NoReturn
 
 if TYPE_CHECKING:  # pragma: no cover
     import types
 
+from enum import Enum
+
 import pytest
-from qiskit import QuantumCircuit
+from qiskit import QuantumCircuit, qpy
 from qiskit.qasm3 import load as load_qasm3
 
-from mqt.bench import utils
 from mqt.bench.benchmark_generation import (
     CompilerSettings,
     QiskitSettings,
+    generate_filename,
     get_alg_level,
     get_benchmark,
     get_indep_level,
     get_mapped_level,
+    get_module_for_benchmark,
     get_native_gates_level,
-    timeout_watcher,
+    get_openqasm_gates,
+    get_supported_benchmarks,
 )
 from mqt.bench.benchmarks import (
     ae,
@@ -46,6 +53,14 @@
     wstate,
 )
 from mqt.bench.devices import IBMProvider, OQCProvider, get_available_providers, get_provider_by_name
+from mqt.bench.output import (
+    MQTBenchExporterError,
+    OutputFormat,
+    __qiskit_version__,
+    generate_header,
+    save_circuit,
+    write_circuit,
+)
 
 
 @pytest.fixture
@@ -120,7 +135,7 @@ def test_quantumcircuit_alg_level(
         return_qc=False,
         target_directory=output_path,
         target_filename=filename,
-        qasm_format="qasm3",
+        output_format=OutputFormat.QASM3,
     )
     assert res
     assert load_qasm3(filepath)
@@ -135,9 +150,9 @@ def test_quantumcircuit_alg_level(
     assert res.num_qubits >= input_value
 
     with pytest.raises(
-        ValueError, match=r"'qasm2' is not supported for the algorithm level, please use 'qasm3' instead."
+        ValueError, match=r"'qasm2' is not supported for the algorithm level; please use e.g. 'qasm3' or 'qpy'."
     ):
-        get_alg_level(qc, input_value, False, False, output_path, filename, qasm_format="qasm2")
+        get_alg_level(qc, input_value, False, False, output_path, filename, output_format=OutputFormat.QASM2)
 
 
 @pytest.mark.parametrize(
@@ -282,7 +297,7 @@ def test_quantumcircuit_native_and_mapped_levels(
 
 def test_openqasm_gates() -> None:
     """Test the openqasm gates."""
-    openqasm_gates = utils.get_openqasm_gates()
+    openqasm_gates = get_openqasm_gates()
     num_openqasm_gates = 42
     assert len(openqasm_gates) == num_openqasm_gates
 
@@ -735,7 +750,7 @@ def test_oqc_benchmarks() -> None:
         return_qc=False,
         target_directory=directory,
         target_filename=filename,
-        qasm_format="qasm2",
+        output_format=OutputFormat.QASM2,
     )
     assert QuantumCircuit.from_qasm_file(str(path))
     path.unlink()
@@ -751,69 +766,17 @@ def test_oqc_benchmarks() -> None:
         return_qc=False,
         target_directory=directory,
         target_filename=filename,
-        qasm_format="qasm2",
+        output_format=OutputFormat.QASM2,
     )
 
     assert QuantumCircuit.from_qasm_file(str(path))
     path.unlink()
 
 
-def test_calc_supermarq_features() -> None:
-    """Test the calculation of the supermarq features."""
-    ghz_qc = get_benchmark("ghz", 1, 5)
-    ghz_features = utils.calc_supermarq_features(ghz_qc)
-    assert ghz_features.program_communication == 0.4
-    assert ghz_features.entanglement_ratio == 0.8
-    assert ghz_features.critical_depth == 1.0
-    assert ghz_features.parallelism == 0.0
-
-    empty_qc = QuantumCircuit(2)
-    empty_features = utils.calc_supermarq_features(empty_qc)
-    assert empty_features.parallelism == 0.0
-    assert empty_features.entanglement_ratio == 0.0
-    assert empty_features.critical_depth == 0.0
-    assert empty_features.program_communication == 0.0
-
-    dense_qc = QuantumCircuit(2)
-    dense_qc.h([0, 1])
-    dense_features = utils.calc_supermarq_features(dense_qc)
-    assert dense_features.parallelism == 1.0
-    assert dense_features.entanglement_ratio == 0.0
-    assert dense_features.critical_depth == 0.0
-    assert dense_features.program_communication == 0.0
-
-
-# This function is used to test the timeout watchers and needs two parameters since those values are logged when a timeout occurs.
-def endless_loop(arg1: SampleObject, run_forever: bool) -> bool:  # noqa: ARG001
-    """Endless loop necessary for testing the timeout watcher."""
-    while run_forever:
-        pass
-    return True
-
-
-class SampleObject:
-    """Sample object for testing the timeout watcher."""
-
-    def __init__(self, name: str) -> None:
-        """Initialize the sample object."""
-        self.name = name
-
-
-def test_timeout_watchers() -> None:
-    """Test the timeout watcher."""
-    timeout = 1
-    if sys.platform == "win32":
-        with pytest.warns(RuntimeWarning, match="Timeout is not supported on Windows."):
-            timeout_watcher(endless_loop, timeout, [SampleObject("test"), False])
-    else:
-        assert not timeout_watcher(endless_loop, timeout, [SampleObject("test"), True])
-        assert timeout_watcher(endless_loop, timeout, [SampleObject("test"), False])
-
-
 def test_get_module_for_benchmark() -> None:
     """Test the get_module_for_benchmark function."""
-    for benchmark in utils.get_supported_benchmarks():
-        assert utils.get_module_for_benchmark(benchmark.split("-")[0]) is not None
+    for benchmark in get_supported_benchmarks():
+        assert get_module_for_benchmark(benchmark.split("-")[0]) is not None
 
 
 def test_benchmark_helper_shor() -> None:
@@ -857,3 +820,223 @@ def test_create_ae_circuit_with_invalid_qubit_number() -> None:
     """Testing the minimum number of qubits in the amplitude estimation circuit."""
     with pytest.raises(ValueError, match=r"Number of qubits must be at least 2 \(1 evaluation \+ 1 target\)."):
         get_benchmark("ae", 1, 1)
+
+
+@pytest.mark.parametrize(
+    ("level", "expected"),
+    [
+        ("alg", "ghz_alg_5"),
+        ("indep", "ghz_indep_5"),
+        ("nativegates", "ghz_nativegates_ibm_opt2_5"),
+        ("mapped", "ghz_mapped_ibm_washington_opt2_5"),
+    ],
+)
+def test_generate_filename(level: str, expected: str) -> None:
+    """Test the generation of a filename."""
+    filename = generate_filename(
+        benchmark_name="ghz",
+        level=level,
+        num_qubits=5,
+        provider_name="ibm",
+        device_name="ibm_washington",
+        opt_level=2,
+    )
+    assert filename == expected
+
+
+@pytest.fixture(autouse=True)
+def temp_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+    """Ensure all files go into a temporary directory."""
+    monkeypatch.chdir(tmp_path)
+    return tmp_path
+
+
+def test_generate_header_minimal(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Test the generation of a minimal header."""
+    monkeypatch.setattr(metadata, "version", lambda _: "9.9.9")
+    hdr = generate_header(OutputFormat.QASM3)
+    lines = hdr.splitlines()
+    # first line has today's date
+    assert lines[0] == f"// Benchmark created by MQT Bench on {date.today()}"
+    # contains the fixed info lines
+    assert "// For more info: https://www.cda.cit.tum.de/mqtbench/" in hdr
+    assert "// MQT Bench version: 9.9.9" in hdr
+    assert f"// Qiskit version: {__qiskit_version__}" in hdr
+    assert f"// Output format: {OutputFormat.QASM3.value}" in hdr
+    # no gate_set or mapping lines when omitted
+    assert "// Used gate set:" not in hdr
+    assert "// Coupling map:" not in hdr
+
+
+def test_generate_header_with_options(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Test the generation of a header with options."""
+    monkeypatch.setattr(metadata, "version", lambda _: "0.1.0")
+    gates = ["x", "cx", "h"]
+    cmap = [[0, 1], [1, 2]]
+    hdr = generate_header(OutputFormat.QASM2, gate_set=gates, c_map=cmap)
+
+    assert f"// Used gate set: {gates}" in hdr
+    assert f"// Coupling map: {cmap}" in hdr
+
+
+def test_generate_header_pkg_not_installed(monkeypatch: pytest.MonkeyPatch) -> None:
+    """metadata.version raises PackageNotFoundError."""
+    monkeypatch.setattr(
+        metadata,
+        "version",
+        lambda _pkg: (_ for _ in ()).throw(MQTBenchExporterError("boom")),
+    )
+    with pytest.raises(MQTBenchExporterError) as exc:
+        generate_header(OutputFormat.QASM2)
+
+    msg = str(exc.value)
+    assert "not installed" in msg.lower()
+    assert "mqt.bench" in msg
+
+
+@pytest.mark.parametrize("fmt", [OutputFormat.QASM2, OutputFormat.QASM3])
+def test_write_circuit_qasm(tmp_path: Path, fmt: OutputFormat) -> None:
+    """Test writing a QASM circuit."""
+    qc = QuantumCircuit(2)
+    qc.h(0)
+    qc.cx(0, 1)
+
+    out = tmp_path / "test.qasm"
+    write_circuit(qc, out, fmt=fmt)
+
+    text = out.read_text().splitlines()
+    # header lines at top
+    assert text[0].startswith("// Benchmark created by MQT Bench on")
+    # QASM body present
+    assert any(line.startswith("h ") for line in text), "H gate should appear"
+    assert any(line.startswith("cx ") for line in text), "CX gate should appear"
+
+
+def test_write_circuit_qpy(tmp_path: Path) -> None:
+    """Test writing a QPY circuit with header embedded in metadata."""
+    qc = QuantumCircuit(1)
+    qc.x(0)
+    out = tmp_path / "test.qpy"
+    write_circuit(qc, out, fmt=OutputFormat.QPY)
+
+    data = out.read_bytes()
+    assert data.startswith(b"QISKIT"), "QPY file must start with the QISKIT magic"
+
+    with out.open("rb") as fd:
+        loaded = list(qpy.load(fd))
+    assert len(loaded) == 1
+    circ = loaded[0]
+    assert isinstance(circ, QuantumCircuit)
+
+    header = circ.metadata["mqt_bench"]
+    assert header.startswith(f"// Benchmark created by MQT Bench on {date.today()}")
+    assert "// MQT Bench version:" in header
+    assert "// Output format: qpy" in header
+
+
+def test_write_circuit_io_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+    """Simulate I/O error while writing."""
+    qc = QuantumCircuit(1)
+    qc.h(0)
+
+    out = tmp_path / "readonly.qasm"
+
+    # Monkey-patch builtins.open to throw OSError on any attempt to open for writing
+    def fake_open(*args: str, **kwargs: str) -> NoReturn:
+        msg = "disk full"
+        raise OSError(msg)
+
+    monkeypatch.setattr(Path, "open", fake_open)
+    monkeypatch.setattr(metadata, "version", lambda _: "0.1.0")
+
+    with pytest.raises(MQTBenchExporterError) as exc:
+        write_circuit(qc, out, fmt=OutputFormat.QASM2)
+
+    msg = str(exc.value)
+    assert "failed to write qasm2 file" in msg.lower()
+    assert "disk full" in msg.lower()
+
+    # restore Path.open so other tests continue unharmed
+    monkeypatch.setattr(Path, "open", builtins.open)
+
+
+def test_write_circuit_unsupported_format(tmp_path: Path) -> None:
+    """Requesting an unsupported format should raise."""
+
+    class FakeFormat(str, Enum):
+        FAKE = "fake"
+
+    qc = QuantumCircuit(1)
+
+    with pytest.raises(MQTBenchExporterError) as exc:
+        write_circuit(qc, tmp_path / "foo.fake", fmt=FakeFormat.FAKE)  # type: ignore[arg-type]
+
+    msg = str(exc.value)
+    assert "unsupported output format" in msg.lower()
+    assert "fake" in msg.lower()
+
+
+def test_save_circuit_success(tmp_path: Path) -> None:
+    """Happy-path save."""
+    qc = QuantumCircuit(1)
+    qc.h(0)
+
+    assert save_circuit(qc, "foo", OutputFormat.QASM2, target_directory=str(tmp_path))
+    assert (tmp_path / "foo.qasm").exists()
+
+    assert save_circuit(qc, "bar", OutputFormat.QPY, target_directory=str(tmp_path))
+    assert (tmp_path / "bar.qpy").exists()
+
+
+def test_save_circuit_write_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+    """save_circuit returns False when write_circuit fails."""
+    qc = QuantumCircuit(1)
+    qc.h(0)
+
+    monkeypatch.setattr(
+        "mqt.bench.output.write_circuit",
+        lambda *_args, **_kwargs: (_ for _ in ()).throw(MQTBenchExporterError("boom")),
+    )
+
+    ok = save_circuit(qc, "baz", OutputFormat.QASM3, target_directory=str(tmp_path))
+    assert ok is False
+
+
+@pytest.mark.parametrize("fmt", [OutputFormat.QASM2, OutputFormat.QASM3])
+def test_write_circuit_qasm_to_text_stream(fmt: OutputFormat) -> None:
+    """Test writing a QASM circuit to a text stream."""
+    qc = QuantumCircuit(2)
+    qc.cx(0, 1)
+
+    buf = io.StringIO()
+    write_circuit(qc, buf, fmt=fmt)
+
+    text = buf.getvalue().splitlines()
+    assert text[0].startswith("// Benchmark created by MQT Bench on")
+    assert any("cx" in line for line in text)
+
+
+def test_write_circuit_qpy_to_binary_stream() -> None:
+    """Test writing a QPY circuit to a binary stream."""
+    qc = QuantumCircuit(1)
+    qc.x(0)
+
+    buf = io.BytesIO()
+    write_circuit(qc, buf, fmt=OutputFormat.QPY)
+
+    buf.seek(0)
+    magic = buf.read(6)
+    assert magic == b"QISKIT"
+
+
+def test_stream_mode_mismatch_raises() -> None:
+    """Test that stream mode mismatch raises an error."""
+    qc = QuantumCircuit(1)
+
+    # Binary stream + QASM → error
+    with pytest.raises(MQTBenchExporterError):
+        write_circuit(qc, io.BytesIO(), fmt=OutputFormat.QASM3)
+
+    # Text stream + QPY → error
+    with pytest.raises(MQTBenchExporterError):
+        write_circuit(qc, io.StringIO(), fmt=OutputFormat.QPY)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index b789885b4..72d9c90f3 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -2,15 +2,17 @@
 
 from __future__ import annotations
 
+from pathlib import Path
 from typing import TYPE_CHECKING
 
 import pytest
-from qiskit.qasm2 import dumps
+from pytest_console_scripts import ScriptRunner
+from qiskit.qasm3 import dumps
 
 from mqt.bench import CompilerSettings, QiskitSettings, get_benchmark
 
 if TYPE_CHECKING:
-    from pytest_console_scripts import ScriptRunner
+    from pytest_console_scripts import ScriptResult, ScriptRunner
 
 
 # fmt: off
@@ -26,6 +28,7 @@
              "--level", "alg",
              "--algorithm", "shor_xsmall",
              "--num-qubits", "10",
+            "--output-format", "qasm2",
          ], "OPENQASM 2.0;"),  # Note: shor is non-deterministic, so just a basic sanity check
         ([
              "--level", "alg",
@@ -42,7 +45,6 @@
              "--algorithm", "ghz",
              "--num-qubits", "20",
              "--native-gate-set", "ibm",
-             "--device", None,
          ], dumps(get_benchmark(level="nativegates", benchmark_name="ghz", circuit_size=20, provider_name="ibm"))),
         ([
              "--level", "mapped",
@@ -72,20 +74,6 @@
             compiler_settings=CompilerSettings(QiskitSettings(optimization_level=2)),
             device_name="ibm_montreal",
         ))),
-        ([
-             "--level", "mapped",
-             "--algorithm", "ghz",
-             "--num-qubits", "20",
-             "--qiskit-optimization-level", "2",
-             "--native-gate-set", None,
-             "--device", "ibm_montreal",
-         ], dumps(get_benchmark(
-            level="mapped",
-            benchmark_name="ghz",
-            circuit_size=20,
-            compiler_settings=CompilerSettings(QiskitSettings(optimization_level=2)),
-            device_name="ibm_montreal",
-        ))),
         (["--help"], "usage: mqt.bench.cli"),
     ],
 )
@@ -116,3 +104,40 @@ def test_cli_errors(args: list[str], expected_output: str, script_runner: Script
     ret = script_runner.run(["mqt.bench.cli", *args])
     assert not ret.success
     assert expected_output in ret.stderr
+
+
+def _run_cli(script_runner: ScriptRunner, extra_args: list[str]) -> ScriptResult:
+    """Run *mqt.bench.cli* with default GHZ/ALG/5 settings plus *extra_args*."""
+    cmd = ["mqt.bench.cli", "--level", "alg", "--algorithm", "ghz", "--num-qubits", "5", *extra_args]
+    return script_runner.run(cmd)
+
+
+@pytest.mark.parametrize("fmt", ["qasm3", "qasm2"])
+def test_cli_qasm_stdout(fmt: str, script_runner: ScriptRunner) -> None:
+    """QASM2/3 should stream directly to stdout when *--save* is omitted."""
+    ret = _run_cli(script_runner, ["--output-format", fmt])
+    assert ret.success
+    assert "// MQT Bench version:" in ret.stdout  # header present
+    assert "OPENQASM" in ret.stdout               # body starts with keyword
+    assert not ret.stderr                   # no unexpected errors
+
+
+def test_cli_qpy_save(tmp_path: Path, script_runner: ScriptRunner) -> None:
+    """When *--save* is given, QPY file is persisted and path is echoed."""
+    target_dir = str(tmp_path)
+    ret = _run_cli(
+        script_runner,
+        [
+            "--output-format",
+            "qpy",
+            "--save",
+            "--target-directory",
+            target_dir,
+        ],
+    )
+    assert ret.success
+
+    expected_path = Path(target_dir) / "ghz_alg_5.qpy"
+    # CLI prints the path on a single line - ensure correctness
+    assert str(expected_path) in ret.stdout.strip().splitlines()[-1]
+    assert expected_path.is_file()


Reviews:
user: login: burgholzer
body: Thanks for pushing this forward!

I think this is already a good step in the right direction. However, let's take this a step further and make this ready for further future extensions.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-22T12:15:26Z
user: login: simon1hofmann
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T12:59:05Z
user: login: burgholzer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T15:14:47Z
user: login: burgholzer
body: Many thanks for all the work on this!
I really like where this is going.
You'll find detailed feedback in the inline comments.
Most likely, this will be good to go after this feedback round.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-22T17:08:07Z
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T11:58:25Z
user: login: burgholzer
body: Nice work, this is looking great!
I especially like the thoroughness of the added tests!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T12:17:13Z

Review comments:
diff_hunk: @@ -450,50 +444,97 @@ def get_benchmark(
     raise ValueError(msg)
 
 
-def timeout_watcher(
-    func: Callable[..., bool | QuantumCircuit],
-    timeout: int,
-    args: list[Any] | int | tuple[int, str] | str,
-) -> bool | QuantumCircuit:
-    """Function to handle timeouts for the benchmark generation."""
-    if sys.platform == "win32":
-        warn("Timeout is not supported on Windows.", category=RuntimeWarning, stacklevel=2)
-        return func(*args) if isinstance(args, (tuple, list)) else func(args)
-
-    class TimeoutExceptionError(Exception):  # Custom exception class
-        """Custom exception class for timeout."""
-
-    def timeout_handler(_signum: int, _frame: Any) -> None:  # noqa: ANN401
-        """Function to handle the timeout."""
-        raise TimeoutExceptionError
-
-    # Change the behavior of SIGALRM
-    signal.signal(signal.SIGALRM, timeout_handler)
-    signal.alarm(timeout)
-    try:
-        res = func(*args) if isinstance(args, (tuple, list)) else func(args)
-    except TimeoutExceptionError:
-        print(
-            "Calculation/Generation exceeded timeout limit for ",
-            func.__name__,
-            func.__module__.split(".")[-1],
-        )
-        if isinstance(args, list) and isinstance(args[0], QuantumCircuit):
-            print(args[0].name, args[1:])
-        else:
-            print(args)
-        return False
-    except Exception as e:
-        print(
-            "Exception: ",
-            e,
-            func.__name__,
-            func.__module__.split(".")[-1],
-            args,
-        )
-        return False
-    else:
-        # Reset the alarm
-        signal.alarm(0)
-
-    return res
+def get_supported_benchmarks() -> list[str]:
+    """Returns a list of all supported benchmarks."""
+    return [
+        "ae",
+        "bv",
+        "dj",
+        "grover-noancilla",
+        "grover-v-chain",
+        "ghz",
+        "graphstate",
+        "qaoa",
+        "qft",
+        "qftentangled",
+        "qnn",
+        "qpeexact",
+        "qpeinexact",
+        "qwalk-noancilla",
+        "qwalk-v-chain",
+        "randomcircuit",
+        "vqerealamprandom",
+        "vqesu2random",
+        "vqetwolocalrandom",
+        "wstate",
+        "shor",
+    ]
+
+
+def get_supported_levels() -> list[str | int]:
+    """Returns a list of all supported benchmark levels."""
+    return ["alg", "indep", "nativegates", "mapped", 0, 1, 2, 3]
+
+
+def get_default_qasm_output_path() -> str:
+    """Returns the path where all .qasm files are stored."""
+    return str(resources.files("mqt.bench") / "viewer" / "static" / "files" / "qasm_output")
+
+
+def get_zip_folder_path() -> str:
+    """Returns the path where the zip file is stored."""
+    return str(resources.files("mqt.bench") / "viewer" / "static" / "files")
path: src/mqt/bench/benchmark_generation.py
user: login: burgholzer
body: More of a side comment unrelated to this PR, but that I noticed because these functions were moved over from the utils module as part of this PR:
These paths no longer seem right as the viewer no longer exists and the generated files do no longer belong inside the `mqt.bench` package.
Furthermore, the name `get_default_qasm_output_path` is no longer really suitable for the future, as we will not only be exporting QASM files (QPY being the easiest extension)

Maybe they aren't needed entirely after the generation logic has been dropped.
created_at: 2025-04-22T12:04:18Z
author_association: MEMBER
path: src/mqt/bench/output.py
user: login: burgholzer
body: Could we already make this slightly more modular?
In the future, this will not only be used for exporting QASM files.
The existing function already does two things that could be separated:
- getting a string representation of a quantum circuit program in some format
- generating a dedicated header for MQT Bench

We probably want the header in all different kinds of outputs in one way or another. So I think it makes sense to have this as a separate function.
In addition, I believe it should be possible to make this slightly more efficient by directly writing to a file (header first, then actual content) and avoiding serialising the program to a string.

Let's try to be as forward thinking as possible here to create a structure that we can easily extend in the future.
It probably makes sense to create some kind of StrEnum for the types of outputs we support.
I would guess that the header function needs to have that as input as well in order to know how to prepare the appropriate header for the respective program.
QPY would be good to incorporate here because it is a binary format that needs different handling than the text-based formats we currently support.
created_at: 2025-04-22T12:13:01Z
author_association: MEMBER
diff_hunk: @@ -450,50 +444,97 @@ def get_benchmark(
     raise ValueError(msg)
 
 
-def timeout_watcher(
-    func: Callable[..., bool | QuantumCircuit],
-    timeout: int,
-    args: list[Any] | int | tuple[int, str] | str,
-) -> bool | QuantumCircuit:
-    """Function to handle timeouts for the benchmark generation."""
-    if sys.platform == "win32":
-        warn("Timeout is not supported on Windows.", category=RuntimeWarning, stacklevel=2)
-        return func(*args) if isinstance(args, (tuple, list)) else func(args)
-
-    class TimeoutExceptionError(Exception):  # Custom exception class
-        """Custom exception class for timeout."""
-
-    def timeout_handler(_signum: int, _frame: Any) -> None:  # noqa: ANN401
-        """Function to handle the timeout."""
-        raise TimeoutExceptionError
-
-    # Change the behavior of SIGALRM
-    signal.signal(signal.SIGALRM, timeout_handler)
-    signal.alarm(timeout)
-    try:
-        res = func(*args) if isinstance(args, (tuple, list)) else func(args)
-    except TimeoutExceptionError:
-        print(
-            "Calculation/Generation exceeded timeout limit for ",
-            func.__name__,
-            func.__module__.split(".")[-1],
-        )
-        if isinstance(args, list) and isinstance(args[0], QuantumCircuit):
-            print(args[0].name, args[1:])
-        else:
-            print(args)
-        return False
-    except Exception as e:
-        print(
-            "Exception: ",
-            e,
-            func.__name__,
-            func.__module__.split(".")[-1],
-            args,
-        )
-        return False
-    else:
-        # Reset the alarm
-        signal.alarm(0)
-
-    return res
+def get_supported_benchmarks() -> list[str]:
+    """Returns a list of all supported benchmarks."""
+    return [
+        "ae",
+        "bv",
+        "dj",
+        "grover-noancilla",
+        "grover-v-chain",
+        "ghz",
+        "graphstate",
+        "qaoa",
+        "qft",
+        "qftentangled",
+        "qnn",
+        "qpeexact",
+        "qpeinexact",
+        "qwalk-noancilla",
+        "qwalk-v-chain",
+        "randomcircuit",
+        "vqerealamprandom",
+        "vqesu2random",
+        "vqetwolocalrandom",
+        "wstate",
+        "shor",
+    ]
+
+
+def get_supported_levels() -> list[str | int]:
+    """Returns a list of all supported benchmark levels."""
+    return ["alg", "indep", "nativegates", "mapped", 0, 1, 2, 3]
+
+
+def get_default_qasm_output_path() -> str:
+    """Returns the path where all .qasm files are stored."""
+    return str(resources.files("mqt.bench") / "viewer" / "static" / "files" / "qasm_output")
+
+
+def get_zip_folder_path() -> str:
+    """Returns the path where the zip file is stored."""
+    return str(resources.files("mqt.bench") / "viewer" / "static" / "files")
path: src/mqt/bench/benchmark_generation.py
user: login: simon1hofmann
body: Both functions are not needed anymore, so I think we could also delete them as part of this PR.
created_at: 2025-04-22T12:59:05Z
author_association: COLLABORATOR
diff_hunk: @@ -450,50 +444,97 @@ def get_benchmark(
     raise ValueError(msg)
 
 
-def timeout_watcher(
-    func: Callable[..., bool | QuantumCircuit],
-    timeout: int,
-    args: list[Any] | int | tuple[int, str] | str,
-) -> bool | QuantumCircuit:
-    """Function to handle timeouts for the benchmark generation."""
-    if sys.platform == "win32":
-        warn("Timeout is not supported on Windows.", category=RuntimeWarning, stacklevel=2)
-        return func(*args) if isinstance(args, (tuple, list)) else func(args)
-
-    class TimeoutExceptionError(Exception):  # Custom exception class
-        """Custom exception class for timeout."""
-
-    def timeout_handler(_signum: int, _frame: Any) -> None:  # noqa: ANN401
-        """Function to handle the timeout."""
-        raise TimeoutExceptionError
-
-    # Change the behavior of SIGALRM
-    signal.signal(signal.SIGALRM, timeout_handler)
-    signal.alarm(timeout)
-    try:
-        res = func(*args) if isinstance(args, (tuple, list)) else func(args)
-    except TimeoutExceptionError:
-        print(
-            "Calculation/Generation exceeded timeout limit for ",
-            func.__name__,
-            func.__module__.split(".")[-1],
-        )
-        if isinstance(args, list) and isinstance(args[0], QuantumCircuit):
-            print(args[0].name, args[1:])
-        else:
-            print(args)
-        return False
-    except Exception as e:
-        print(
-            "Exception: ",
-            e,
-            func.__name__,
-            func.__module__.split(".")[-1],
-            args,
-        )
-        return False
-    else:
-        # Reset the alarm
-        signal.alarm(0)
-
-    return res
+def get_supported_benchmarks() -> list[str]:
+    """Returns a list of all supported benchmarks."""
+    return [
+        "ae",
+        "bv",
+        "dj",
+        "grover-noancilla",
+        "grover-v-chain",
+        "ghz",
+        "graphstate",
+        "qaoa",
+        "qft",
+        "qftentangled",
+        "qnn",
+        "qpeexact",
+        "qpeinexact",
+        "qwalk-noancilla",
+        "qwalk-v-chain",
+        "randomcircuit",
+        "vqerealamprandom",
+        "vqesu2random",
+        "vqetwolocalrandom",
+        "wstate",
+        "shor",
+    ]
+
+
+def get_supported_levels() -> list[str | int]:
+    """Returns a list of all supported benchmark levels."""
+    return ["alg", "indep", "nativegates", "mapped", 0, 1, 2, 3]
+
+
+def get_default_qasm_output_path() -> str:
+    """Returns the path where all .qasm files are stored."""
+    return str(resources.files("mqt.bench") / "viewer" / "static" / "files" / "qasm_output")
+
+
+def get_zip_folder_path() -> str:
+    """Returns the path where the zip file is stored."""
+    return str(resources.files("mqt.bench") / "viewer" / "static" / "files")
path: src/mqt/bench/benchmark_generation.py
user: login: burgholzer
body: Great 👍🏼 then let's remove them
created_at: 2025-04-22T15:14:47Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,154 @@
+"""Output functions for the MQT Bench module."""
+
+from __future__ import annotations
+
+from datetime import date
+from enum import Enum
+from importlib import metadata
+from pathlib import Path
+
+from qiskit import QuantumCircuit
+from qiskit import __version__ as __qiskit_version__
+from qiskit.qasm2 import dumps as dumps2
+from qiskit.qasm3 import dumps as dumps3
+from qiskit.qpy import dump as dump_qpy
+
+
+class OutputFormat(str, Enum):
+    """Enumeration of supported output formats for circuit export."""
+
+    QASM2 = "qasm2"
+    QASM3 = "qasm3"
+    QPY = "qpy"
+
+
+class MQTBenchExporterError(Exception):
+    """Custom exception for errors arising during MQT Bench exporting operations."""
+
+
+def generate_header(
+    fmt: OutputFormat,
+    gate_set: list[str] | None = None,
+    mapped: bool = False,
+    c_map: list[list[int]] | None = None,
path: src/mqt/bench/output.py
user: login: burgholzer
body: The `mapped` option is kind of redundant here as the presence of a `c_map` already indicates that this information is supposed to be included.
Might as well use the opportunity to deduplicate this.
Same argument goes for the remaining functions in this file.
created_at: 2025-04-22T16:40:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,154 @@
+"""Output functions for the MQT Bench module."""
+
+from __future__ import annotations
+
+from datetime import date
+from enum import Enum
+from importlib import metadata
+from pathlib import Path
+
+from qiskit import QuantumCircuit
+from qiskit import __version__ as __qiskit_version__
+from qiskit.qasm2 import dumps as dumps2
+from qiskit.qasm3 import dumps as dumps3
+from qiskit.qpy import dump as dump_qpy
+
+
+class OutputFormat(str, Enum):
+    """Enumeration of supported output formats for circuit export."""
+
+    QASM2 = "qasm2"
+    QASM3 = "qasm3"
+    QPY = "qpy"
+
+
+class MQTBenchExporterError(Exception):
+    """Custom exception for errors arising during MQT Bench exporting operations."""
+
+
+def generate_header(
+    fmt: OutputFormat,
+    gate_set: list[str] | None = None,
+    mapped: bool = False,
+    c_map: list[list[int]] | None = None,
+) -> str:
+    """Generate a standardized header for MQT Bench outputs.
+
+    Arguments:
+        fmt: The chosen output format enum member.
+        gate_set: Optional list of gate names used in the circuit.
+        mapped: Whether the circuit was mapped to hardware.
+        c_map: Optional coupling map of the hardware layout.
+
+    Returns:
+        A string containing the formatted header.
+    """
+    try:
+        version = metadata.version("mqt.bench")
+    except Exception:
+        msg = "Could not retrieve 'mqt.bench' version. Is the package installed?"
+        raise MQTBenchExporterError(msg) from None
+
+    lines: list[str] = []
+    lines.extend((
+        f"// Benchmark created by MQT Bench on {date.today()}",
+        "// For more info: https://www.cda.cit.tum.de/mqtbench/",
+        f"// MQT Bench version: {version}",
+        f"// Qiskit version: {__qiskit_version__}",
+        f"// Output format: {fmt.value}",
+    ))
+
+    if gate_set:
+        lines.append(f"// Used gate set: {gate_set}")
+    if mapped:
+        lines.append(f"// Coupling map: {c_map or []}")
+
+    return "\n".join(lines) + "\n\n"
+
+
+def write_circuit(
+    qc: QuantumCircuit,
+    file_path: Path,
+    fmt: OutputFormat = OutputFormat.QASM2,
path: src/mqt/bench/output.py
user: login: burgholzer
body: Not that it matters too much, but this should probably default to QASM3.
```suggestion
    fmt: OutputFormat = OutputFormat.QASM3,
```
created_at: 2025-04-22T16:42:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,154 @@
+"""Output functions for the MQT Bench module."""
+
+from __future__ import annotations
+
+from datetime import date
+from enum import Enum
+from importlib import metadata
+from pathlib import Path
+
+from qiskit import QuantumCircuit
+from qiskit import __version__ as __qiskit_version__
+from qiskit.qasm2 import dumps as dumps2
+from qiskit.qasm3 import dumps as dumps3
+from qiskit.qpy import dump as dump_qpy
+
+
+class OutputFormat(str, Enum):
+    """Enumeration of supported output formats for circuit export."""
+
+    QASM2 = "qasm2"
+    QASM3 = "qasm3"
+    QPY = "qpy"
+
+
+class MQTBenchExporterError(Exception):
+    """Custom exception for errors arising during MQT Bench exporting operations."""
+
+
+def generate_header(
+    fmt: OutputFormat,
+    gate_set: list[str] | None = None,
+    mapped: bool = False,
+    c_map: list[list[int]] | None = None,
+) -> str:
+    """Generate a standardized header for MQT Bench outputs.
+
+    Arguments:
+        fmt: The chosen output format enum member.
+        gate_set: Optional list of gate names used in the circuit.
+        mapped: Whether the circuit was mapped to hardware.
+        c_map: Optional coupling map of the hardware layout.
+
+    Returns:
+        A string containing the formatted header.
+    """
+    try:
+        version = metadata.version("mqt.bench")
+    except Exception:
+        msg = "Could not retrieve 'mqt.bench' version. Is the package installed?"
+        raise MQTBenchExporterError(msg) from None
+
+    lines: list[str] = []
+    lines.extend((
+        f"// Benchmark created by MQT Bench on {date.today()}",
+        "// For more info: https://www.cda.cit.tum.de/mqtbench/",
+        f"// MQT Bench version: {version}",
+        f"// Qiskit version: {__qiskit_version__}",
+        f"// Output format: {fmt.value}",
+    ))
+
+    if gate_set:
+        lines.append(f"// Used gate set: {gate_set}")
+    if mapped:
+        lines.append(f"// Coupling map: {c_map or []}")
+
+    return "\n".join(lines) + "\n\n"
+
+
+def write_circuit(
+    qc: QuantumCircuit,
+    file_path: Path,
+    fmt: OutputFormat = OutputFormat.QASM2,
+    gate_set: list[str] | None = None,
+    mapped: bool = False,
+    c_map: list[list[int]] | None = None,
+) -> None:
+    """Write the given quantum circuit to disk in the specified format, preceded by an MQT Bench header.
+
+    Args:
+        qc: The QuantumCircuit to export.
+        file_path: Destination file path (including extension).
+        fmt: Desired output format.
+        gate_set: Optional gate set list.
+        mapped: Whether the circuit is hardware-mapped.
+        c_map: Optional coupling map.
+
+    Raises:
+        MQTBenchExporterError: On unsupported format or I/O errors.
+    """
+    header = generate_header(fmt, gate_set, mapped, c_map)
+
+    # Choose write mode and serialization
+    if fmt in (OutputFormat.QASM2, OutputFormat.QASM3):
+        serial = dumps2(qc) if fmt == OutputFormat.QASM2 else dumps3(qc)
+        try:
+            with Path.open(file_path, "w", encoding="utf-8") as f:
+                f.write(header)
+                f.write(serial)
+        except Exception as e:
+            msg = f"Failed to write QASM file: {e}"
+            raise MQTBenchExporterError(msg) from None
path: src/mqt/bench/output.py
user: login: burgholzer
body: Does it make sense here to make it explicit whether QASM2 or QASM3 were used and failed? Would probably make for a better error message.
created_at: 2025-04-22T16:44:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,154 @@
+"""Output functions for the MQT Bench module."""
+
+from __future__ import annotations
+
+from datetime import date
+from enum import Enum
+from importlib import metadata
+from pathlib import Path
+
+from qiskit import QuantumCircuit
+from qiskit import __version__ as __qiskit_version__
+from qiskit.qasm2 import dumps as dumps2
+from qiskit.qasm3 import dumps as dumps3
+from qiskit.qpy import dump as dump_qpy
+
+
+class OutputFormat(str, Enum):
+    """Enumeration of supported output formats for circuit export."""
+
+    QASM2 = "qasm2"
+    QASM3 = "qasm3"
+    QPY = "qpy"
+
+
+class MQTBenchExporterError(Exception):
+    """Custom exception for errors arising during MQT Bench exporting operations."""
+
+
+def generate_header(
+    fmt: OutputFormat,
+    gate_set: list[str] | None = None,
+    mapped: bool = False,
+    c_map: list[list[int]] | None = None,
+) -> str:
+    """Generate a standardized header for MQT Bench outputs.
+
+    Arguments:
+        fmt: The chosen output format enum member.
+        gate_set: Optional list of gate names used in the circuit.
+        mapped: Whether the circuit was mapped to hardware.
+        c_map: Optional coupling map of the hardware layout.
+
+    Returns:
+        A string containing the formatted header.
+    """
+    try:
+        version = metadata.version("mqt.bench")
+    except Exception:
+        msg = "Could not retrieve 'mqt.bench' version. Is the package installed?"
+        raise MQTBenchExporterError(msg) from None
+
+    lines: list[str] = []
+    lines.extend((
+        f"// Benchmark created by MQT Bench on {date.today()}",
+        "// For more info: https://www.cda.cit.tum.de/mqtbench/",
+        f"// MQT Bench version: {version}",
+        f"// Qiskit version: {__qiskit_version__}",
+        f"// Output format: {fmt.value}",
+    ))
+
+    if gate_set:
+        lines.append(f"// Used gate set: {gate_set}")
+    if mapped:
+        lines.append(f"// Coupling map: {c_map or []}")
+
+    return "\n".join(lines) + "\n\n"
+
+
+def write_circuit(
+    qc: QuantumCircuit,
+    file_path: Path,
+    fmt: OutputFormat = OutputFormat.QASM2,
+    gate_set: list[str] | None = None,
+    mapped: bool = False,
+    c_map: list[list[int]] | None = None,
+) -> None:
+    """Write the given quantum circuit to disk in the specified format, preceded by an MQT Bench header.
+
+    Args:
+        qc: The QuantumCircuit to export.
+        file_path: Destination file path (including extension).
+        fmt: Desired output format.
+        gate_set: Optional gate set list.
+        mapped: Whether the circuit is hardware-mapped.
+        c_map: Optional coupling map.
+
+    Raises:
+        MQTBenchExporterError: On unsupported format or I/O errors.
+    """
+    header = generate_header(fmt, gate_set, mapped, c_map)
+
+    # Choose write mode and serialization
+    if fmt in (OutputFormat.QASM2, OutputFormat.QASM3):
+        serial = dumps2(qc) if fmt == OutputFormat.QASM2 else dumps3(qc)
+        try:
+            with Path.open(file_path, "w", encoding="utf-8") as f:
+                f.write(header)
+                f.write(serial)
+        except Exception as e:
+            msg = f"Failed to write QASM file: {e}"
+            raise MQTBenchExporterError(msg) from None
+
+    elif fmt == OutputFormat.QPY:
+        try:
+            with Path.open(file_path, "wb") as f:
+                f.write(header.encode("utf-8"))
+                dump_qpy(qc, f)
+        except Exception as e:
+            msg = f"Failed to write QPY file: {e}"
+            raise MQTBenchExporterError(msg) from None
+
+    else:
+        msg = f"Unsupported output format: {fmt}"
+        raise MQTBenchExporterError(msg) from None
+
+
+def save_circuit(
+    qc: QuantumCircuit,
+    filename: str,
+    output_format: str = OutputFormat.QASM2.value,
path: src/mqt/bench/output.py
user: login: burgholzer
body: This should most certainly be an `Enum` instead of a `str` parameter. See also the comment in the other main file.
created_at: 2025-04-22T16:46:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,154 @@
+"""Output functions for the MQT Bench module."""
+
+from __future__ import annotations
+
+from datetime import date
+from enum import Enum
+from importlib import metadata
+from pathlib import Path
+
+from qiskit import QuantumCircuit
+from qiskit import __version__ as __qiskit_version__
+from qiskit.qasm2 import dumps as dumps2
+from qiskit.qasm3 import dumps as dumps3
+from qiskit.qpy import dump as dump_qpy
+
+
+class OutputFormat(str, Enum):
+    """Enumeration of supported output formats for circuit export."""
+
+    QASM2 = "qasm2"
+    QASM3 = "qasm3"
+    QPY = "qpy"
+
+
+class MQTBenchExporterError(Exception):
+    """Custom exception for errors arising during MQT Bench exporting operations."""
+
+
+def generate_header(
+    fmt: OutputFormat,
+    gate_set: list[str] | None = None,
+    mapped: bool = False,
+    c_map: list[list[int]] | None = None,
+) -> str:
+    """Generate a standardized header for MQT Bench outputs.
+
+    Arguments:
+        fmt: The chosen output format enum member.
+        gate_set: Optional list of gate names used in the circuit.
+        mapped: Whether the circuit was mapped to hardware.
+        c_map: Optional coupling map of the hardware layout.
+
+    Returns:
+        A string containing the formatted header.
+    """
+    try:
+        version = metadata.version("mqt.bench")
+    except Exception:
+        msg = "Could not retrieve 'mqt.bench' version. Is the package installed?"
+        raise MQTBenchExporterError(msg) from None
+
+    lines: list[str] = []
+    lines.extend((
+        f"// Benchmark created by MQT Bench on {date.today()}",
+        "// For more info: https://www.cda.cit.tum.de/mqtbench/",
+        f"// MQT Bench version: {version}",
+        f"// Qiskit version: {__qiskit_version__}",
+        f"// Output format: {fmt.value}",
+    ))
+
+    if gate_set:
+        lines.append(f"// Used gate set: {gate_set}")
+    if mapped:
+        lines.append(f"// Coupling map: {c_map or []}")
+
+    return "\n".join(lines) + "\n\n"
+
+
+def write_circuit(
+    qc: QuantumCircuit,
+    file_path: Path,
+    fmt: OutputFormat = OutputFormat.QASM2,
+    gate_set: list[str] | None = None,
+    mapped: bool = False,
+    c_map: list[list[int]] | None = None,
+) -> None:
+    """Write the given quantum circuit to disk in the specified format, preceded by an MQT Bench header.
+
+    Args:
+        qc: The QuantumCircuit to export.
+        file_path: Destination file path (including extension).
+        fmt: Desired output format.
+        gate_set: Optional gate set list.
+        mapped: Whether the circuit is hardware-mapped.
+        c_map: Optional coupling map.
+
+    Raises:
+        MQTBenchExporterError: On unsupported format or I/O errors.
+    """
+    header = generate_header(fmt, gate_set, mapped, c_map)
+
+    # Choose write mode and serialization
+    if fmt in (OutputFormat.QASM2, OutputFormat.QASM3):
+        serial = dumps2(qc) if fmt == OutputFormat.QASM2 else dumps3(qc)
+        try:
+            with Path.open(file_path, "w", encoding="utf-8") as f:
+                f.write(header)
+                f.write(serial)
path: src/mqt/bench/output.py
user: login: burgholzer
body: Just a thought: can we avoid dumping to a string before we dump to a file here?
Qiskit has `dump` alternatives to the `dumps` variants.
I could see the header being a problem. However, I feel like it could be comparatively easy to add that header in retrospect.
Do you see this as an option?
Given how much we export as part of MQT Bench (it's one of its main purposes after all), this should be as fast as possible.

Another afterthought: Qiskit circuits have a `metadata` attribute if I am not mistaken. Could we be using that to save the header information? At least for QPY that could work.
created_at: 2025-04-22T16:49:44Z
author_association: MEMBER
diff_hunk: @@ -72,7 +67,7 @@ def get_alg_level(
     return_qc: bool = False,
     target_directory: str = "./",
     target_filename: str = "",
-    qasm_format: str = "qasm3",
+    output_format: str = OutputFormat.QASM3.value,
path: src/mqt/bench/benchmark_generation.py
user: login: burgholzer
body: I believe it would be fair to replace these `str` parameters with enum values. They are the only valid options here and that is the additional type safety that enums provide. Holds throughout this file for code and docstrings.
created_at: 2025-04-22T16:52:23Z
author_association: MEMBER
diff_hunk: @@ -93,17 +87,23 @@ def get_alg_level(
     if return_qc:
         return qc
 
-    if qasm_format == "qasm2":
-        msg = "'qasm2' is not supported for the algorithm level, please use 'qasm3' instead."
+    if output_format == OutputFormat.QASM2.value:
+        msg = "'qasm2' is not supported for the algorithm level; please use 'qasm3' or 'qpy'."
         raise ValueError(msg)
-    filename_alg = target_filename or qc.name + "_alg_" + str(num_qubits) + "_" + qasm_format
 
-    path = Path(target_directory, filename_alg + ".qasm")
+    filename_alg = target_filename or f"{qc.name}_alg_{num_qubits}_{output_format}"
+    file_ext = "qasm" if output_format in (OutputFormat.QASM2.value, OutputFormat.QASM3.value) else output_format
path: src/mqt/bench/benchmark_generation.py
user: login: burgholzer
body: Could this be something to pull out into a separate function close to the original enum definition? maybe even as a method of the enum class?
Getting the extension for an output format seems so fundamental, that it kind of makes sense to have it closely located to the output format definition.
created_at: 2025-04-22T16:55:33Z
author_association: MEMBER
diff_hunk: @@ -221,34 +230,34 @@ def get_native_gates_level(
         return_qc: flag if the actual circuit shall be returned
         target_directory: alternative directory to the default one to store the created circuit
         target_filename: alternative filename to the default one
-        qasm_format: qasm format (qasm2 or qasm3)
+        output_format: one of supported formats ('qasm3', 'qasm2', 'qpy')
path: src/mqt/bench/benchmark_generation.py
user: login: burgholzer
body: I would probably rephrase this docstring (and any others like it) to avoid listing explicit formats as this is prone to get out of date with new formats being added. Simply add a reference to the Output format enum, which contains the respective docs and lists the supported formats. That should make these docstrings future proof.
created_at: 2025-04-22T16:57:21Z
author_association: MEMBER
diff_hunk: @@ -23,56 +26,127 @@ def format_help(self) -> str:
         return help_message + version_info
 
 
-def main() -> None:
-    """Invoke :func:`get_benchmark` exactly once with the specified arguments.
+def parse_benchmark_name_and_instance(algorithm: str) -> tuple[str, str | None]:
+    """Parse an algorithm name like "shor_xlarge" into a benchmark and instance name."""
+    if algorithm.startswith("shor_"):
+        parts = algorithm.split("_", 1)
+        return parts[0], parts[1]
+    return algorithm, None
+
 
-    The resulting QASM string is printed to the standard output stream;
-    no other output is generated.
-    """
+def main() -> None:
+    """Generate a single benchmark and output in specified format."""
     parser = CustomArgumentParser(description="Generate a single benchmark")
     parser.add_argument(
         "--level",
         type=str,
-        help='Level to generate benchmarks for ("alg", "indep", "nativegates" or "mapped")',
+        choices=["alg", "indep", "nativegates", "mapped"],
+        help='Level to generate benchmarks for ("alg", "indep", "nativegates" or "mapped").',
+        required=True,
+    )
+    parser.add_argument(
+        "--algorithm",
+        type=str,
+        help="Name of the benchmark (e.g., 'grover-v-chain', 'shor_xsmall').",
         required=True,
     )
-    parser.add_argument("--algorithm", type=str, help="Name of the benchmark", required=True)
-    parser.add_argument("--num-qubits", type=int, help="Number of Qubits", required=True)
-    parser.add_argument("--qiskit-optimization-level", type=int, help="Qiskit compiler optimization level")
-    parser.add_argument("--native-gate-set", type=str, help="Name of the provider")
-    parser.add_argument("--device", type=str, help="Name of the device")
+    parser.add_argument(
+        "--num-qubits",
+        type=int,
+        help="Number of qubits for the benchmark.",
+        required=True,
+    )
+    parser.add_argument(
+        "--qiskit-optimization-level",
+        type=int,
+        help="Qiskit compiler optimization level (0-3).",
+    )
+    parser.add_argument(
+        "--native-gate-set",
+        type=str,
+        help="Provider name for native gate set (e.g., 'ibm', 'rigetti').",
+    )
+    parser.add_argument(
+        "--device",
+        type=str,
+        help="Device name for mapping stage (e.g., 'ibm_washington').",
+    )
+    parser.add_argument(
+        "--output-format",
+        type=str,
+        choices=[fmt.value for fmt in OutputFormat],
+        default=OutputFormat.QASM2.value,
+        help="Output format: 'qasm2', 'qasm3', or 'qpy'.",
+    )
+    parser.add_argument(
+        "--target-directory",
+        type=str,
+        default=".",
+        help="Directory to save the output file (only used for 'qpy' or if --save is specified).",
+    )
+    parser.add_argument(
+        "--target-filename",
+        type=str,
+        default="",
+        help="Base filename (without extension) for saved output.",
+    )
+    parser.add_argument(
+        "--save",
+        action="store_true",
+        help="If set, save the output to a file instead of printing to stdout (required for 'qpy').",
+    )
+
     args = parser.parse_args()
 
+    # Build Qiskit settings
     qiskit_settings = QiskitSettings()
     if args.qiskit_optimization_level is not None:
         qiskit_settings = QiskitSettings(args.qiskit_optimization_level)
 
-    # Note: Assertions about argument validity are in get_benchmark()
-
-    # Temporary workaround to "get things working" with benchmark instances.
-    # This special treatment should be removed as soon as instance handling has been refactored in get_benchmark().
+    # Parse algorithm and optional instance
     benchmark_name, benchmark_instance = parse_benchmark_name_and_instance(args.algorithm)
 
-    result = get_benchmark(
+    # Generate circuit
+    circuit = get_benchmark(
         benchmark_name=benchmark_name,
         benchmark_instance_name=benchmark_instance,
         level=args.level,
         circuit_size=args.num_qubits,
-        compiler_settings=CompilerSettings(
-            qiskit=qiskit_settings,
-        ),
-        provider_name=args.native_gate_set,
-        device_name=args.device,
+        compiler_settings=CompilerSettings(qiskit=qiskit_settings),
+        provider_name=args.native_gate_set or "ibm",
+        device_name=args.device or "ibm_washington",
     )
-    print(qiskit_circuit_to_str(result))
-    return
 
+    try:
+        fmt = OutputFormat(args.output_format)
+    except ValueError:
+        msg = f"Unknown output format: {args.output_format}"
+        raise ValueError(msg) from None
+
+    # For QASM outputs, serialize and print
+    if fmt in (OutputFormat.QASM2, OutputFormat.QASM3) and not args.save:
+        header = generate_header(fmt)
+        serial = qasm2_dumps(circuit) if fmt == OutputFormat.QASM2 else qasm3_dumps(circuit)
+        sys.stdout.write(header)
+        sys.stdout.write(serial)
+        return
+
+    # Otherwise, save to file
+    filename = args.target_filename or f"{benchmark_name}_{args.level}_{args.num_qubits}_{fmt.value}"
+    success = save_circuit(
+        qc=circuit,
+        filename=filename,
+        output_format=str(fmt.value),
+        target_directory=args.target_directory,
+    )
+    if not success:
+        sys.exit(1)
+    # Optionally, inform user of file location if saving
+    if args.save or fmt == OutputFormat.QPY:
+        ext = "qasm" if fmt in (OutputFormat.QASM2, OutputFormat.QASM3) else fmt.value
+        path = Path(args.target_directory) / f"{filename}.{ext}"
+        print(path)
path: src/mqt/bench/cli.py
user: login: burgholzer
body: It would be nice to not replicate too much logic here.
Can we somehow centralise this in the `output.py` file?
This is not so different to writing to a file. It just writes to stdout instead.
created_at: 2025-04-22T17:00:26Z
author_association: MEMBER
diff_hunk: @@ -23,56 +26,127 @@ def format_help(self) -> str:
         return help_message + version_info
 
 
-def main() -> None:
-    """Invoke :func:`get_benchmark` exactly once with the specified arguments.
+def parse_benchmark_name_and_instance(algorithm: str) -> tuple[str, str | None]:
+    """Parse an algorithm name like "shor_xlarge" into a benchmark and instance name."""
+    if algorithm.startswith("shor_"):
+        parts = algorithm.split("_", 1)
+        return parts[0], parts[1]
+    return algorithm, None
+
 
-    The resulting QASM string is printed to the standard output stream;
-    no other output is generated.
-    """
+def main() -> None:
+    """Generate a single benchmark and output in specified format."""
     parser = CustomArgumentParser(description="Generate a single benchmark")
     parser.add_argument(
         "--level",
         type=str,
-        help='Level to generate benchmarks for ("alg", "indep", "nativegates" or "mapped")',
+        choices=["alg", "indep", "nativegates", "mapped"],
+        help='Level to generate benchmarks for ("alg", "indep", "nativegates" or "mapped").',
+        required=True,
+    )
+    parser.add_argument(
+        "--algorithm",
+        type=str,
+        help="Name of the benchmark (e.g., 'grover-v-chain', 'shor_xsmall').",
         required=True,
     )
-    parser.add_argument("--algorithm", type=str, help="Name of the benchmark", required=True)
-    parser.add_argument("--num-qubits", type=int, help="Number of Qubits", required=True)
-    parser.add_argument("--qiskit-optimization-level", type=int, help="Qiskit compiler optimization level")
-    parser.add_argument("--native-gate-set", type=str, help="Name of the provider")
-    parser.add_argument("--device", type=str, help="Name of the device")
+    parser.add_argument(
+        "--num-qubits",
+        type=int,
+        help="Number of qubits for the benchmark.",
+        required=True,
+    )
+    parser.add_argument(
+        "--qiskit-optimization-level",
+        type=int,
+        help="Qiskit compiler optimization level (0-3).",
+    )
+    parser.add_argument(
+        "--native-gate-set",
+        type=str,
+        help="Provider name for native gate set (e.g., 'ibm', 'rigetti').",
+    )
+    parser.add_argument(
+        "--device",
+        type=str,
+        help="Device name for mapping stage (e.g., 'ibm_washington').",
+    )
+    parser.add_argument(
+        "--output-format",
+        type=str,
+        choices=[fmt.value for fmt in OutputFormat],
+        default=OutputFormat.QASM2.value,
path: src/mqt/bench/cli.py
user: login: burgholzer
body: ```suggestion
        default=OutputFormat.QASM3.value,
```
probably more future proof
created_at: 2025-04-22T17:01:39Z
author_association: MEMBER
diff_hunk: @@ -23,56 +26,127 @@ def format_help(self) -> str:
         return help_message + version_info
 
 
-def main() -> None:
-    """Invoke :func:`get_benchmark` exactly once with the specified arguments.
+def parse_benchmark_name_and_instance(algorithm: str) -> tuple[str, str | None]:
+    """Parse an algorithm name like "shor_xlarge" into a benchmark and instance name."""
+    if algorithm.startswith("shor_"):
+        parts = algorithm.split("_", 1)
+        return parts[0], parts[1]
+    return algorithm, None
+
 
-    The resulting QASM string is printed to the standard output stream;
-    no other output is generated.
-    """
+def main() -> None:
+    """Generate a single benchmark and output in specified format."""
     parser = CustomArgumentParser(description="Generate a single benchmark")
     parser.add_argument(
         "--level",
         type=str,
-        help='Level to generate benchmarks for ("alg", "indep", "nativegates" or "mapped")',
+        choices=["alg", "indep", "nativegates", "mapped"],
+        help='Level to generate benchmarks for ("alg", "indep", "nativegates" or "mapped").',
+        required=True,
+    )
+    parser.add_argument(
+        "--algorithm",
+        type=str,
+        help="Name of the benchmark (e.g., 'grover-v-chain', 'shor_xsmall').",
         required=True,
     )
-    parser.add_argument("--algorithm", type=str, help="Name of the benchmark", required=True)
-    parser.add_argument("--num-qubits", type=int, help="Number of Qubits", required=True)
-    parser.add_argument("--qiskit-optimization-level", type=int, help="Qiskit compiler optimization level")
-    parser.add_argument("--native-gate-set", type=str, help="Name of the provider")
-    parser.add_argument("--device", type=str, help="Name of the device")
+    parser.add_argument(
+        "--num-qubits",
+        type=int,
+        help="Number of qubits for the benchmark.",
+        required=True,
+    )
+    parser.add_argument(
+        "--qiskit-optimization-level",
+        type=int,
+        help="Qiskit compiler optimization level (0-3).",
+    )
+    parser.add_argument(
+        "--native-gate-set",
+        type=str,
+        help="Provider name for native gate set (e.g., 'ibm', 'rigetti').",
+    )
+    parser.add_argument(
+        "--device",
+        type=str,
+        help="Device name for mapping stage (e.g., 'ibm_washington').",
+    )
+    parser.add_argument(
+        "--output-format",
+        type=str,
+        choices=[fmt.value for fmt in OutputFormat],
+        default=OutputFormat.QASM2.value,
+        help="Output format: 'qasm2', 'qasm3', or 'qpy'.",
+    )
+    parser.add_argument(
+        "--target-directory",
+        type=str,
+        default=".",
+        help="Directory to save the output file (only used for 'qpy' or if --save is specified).",
+    )
+    parser.add_argument(
+        "--target-filename",
+        type=str,
+        default="",
+        help="Base filename (without extension) for saved output.",
+    )
+    parser.add_argument(
+        "--save",
+        action="store_true",
+        help="If set, save the output to a file instead of printing to stdout (required for 'qpy').",
+    )
path: src/mqt/bench/cli.py
user: login: burgholzer
body: I like this addition of writing to a file from the CLI instead of printing to stdout.
I'd probably slightly rephrase the existing help text for the `save` flag to just mention `qpy` as one of the examples that is not available as text-based output.
Additionally, I do not think that the `--target-filename` argument is necessary or helpful, but maybe I am overlooking things. MQT Bench has its naming scheme for files and I am not sure the user should be allowed to mess with it.
Of course, there needs to be some way for the user to specify where things should get stored.
created_at: 2025-04-22T17:05:25Z
author_association: MEMBER
diff_hunk: @@ -116,3 +102,40 @@ def test_cli_errors(args: list[str], expected_output: str, script_runner: Script
     ret = script_runner.run(["mqt.bench.cli", *args])
     assert not ret.success
     assert expected_output in ret.stderr
+
+
+def test_cli_output_formats_and_save(tmp_path: Path, script_runner: ScriptRunner) -> None:
+    """Test output formats and save functionality."""
+    # Test QASM3 prints to stdout when not saving
+    ret_qasm3 = script_runner.run([
+        "mqt.bench.cli",
+        "--level", "alg",
+        "--algorithm", "ghz",
+        "--num-qubits", "5",
+        "--output-format", "qasm3",
+    ])
+    assert ret_qasm3.success
+    # QASM3 header should appear and not relate to save
+    assert "MQT Bench version:" in ret_qasm3.stdout
+    assert "OPENQASM" in ret_qasm3.stdout
+
+    # Test saving QPY to a file
+    target_dir = str(tmp_path)
+    target_file = "mybench"
+    ret_qpy = script_runner.run([
+        "mqt.bench.cli",
+        "--level", "alg",
+        "--algorithm", "ghz",
+        "--num-qubits", "5",
+        "--output-format", "qpy",
+        "--save",
+        "--target-directory", target_dir,
+        "--target-filename", target_file,
+    ])
+    # File should exist
+    assert ret_qpy.success
+    # Should output the path to the saved file
+    expected_path = str(Path(target_dir) / f"{target_file}.qpy")
+    assert expected_path in ret_qpy.stdout
+    # File should exist
+    assert (tmp_path / f"{target_file}.qpy").is_file()
path: tests/test_cli.py
user: login: burgholzer
body: Might be better as individual smaller tests to kind of keep the spirit of a "unit" test.
created_at: 2025-04-22T17:07:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,211 @@
+"""Output functions for the MQT Bench module."""
+
+from __future__ import annotations
+
+from datetime import date
+from enum import Enum
+from importlib import metadata
+from io import TextIOBase
+from pathlib import Path
+from typing import TYPE_CHECKING, TextIO, overload
+
+from qiskit import QuantumCircuit
+from qiskit import __version__ as __qiskit_version__
+from qiskit.qasm2 import dump as dump2
+from qiskit.qasm3 import dump as dump3
+from qiskit.qpy import dump as dump_qpy
+
+if TYPE_CHECKING:  # pragma: no cover
+    from typing import BinaryIO
+
+
+class OutputFormat(str, Enum):
+    """Enumeration of supported output formats for circuit export."""
+
+    QASM2 = "qasm2"
+    QASM3 = "qasm3"
+    QPY = "qpy"
+
+    def extension(self) -> str:
+        """Return the canonical filename extension for this format."""
+        return "qasm" if self in (OutputFormat.QASM2, OutputFormat.QASM3) else self.value
+
+
+class MQTBenchExporterError(Exception):
+    """Custom exception for errors arising during MQT Bench exporting operations."""
+
+
+def _attach_metadata(qc: QuantumCircuit, header: str) -> QuantumCircuit:
+    """Return *a shallow copy* whose ``metadata`` carries the MQT-Bench header."""
+    clone = qc.copy()
+    clone.metadata = (clone.metadata or {}) | {"mqt_bench": header}
+    return clone
+
+
+def generate_header(
+    fmt: OutputFormat,
+    gate_set: list[str] | None = None,
+    c_map: list[list[int]] | None = None,
+) -> str:
+    """Generate a standardized header for MQT Bench outputs.
+
+    Arguments:
+        fmt: The chosen output format enum member
+        gate_set: Optional list of gate names used in the circuit
+        c_map: Optional coupling map of the hardware layout
+
+    Returns:
+        A string containing the formatted header.
+    """
+    try:
+        version = metadata.version("mqt.bench")
+    except Exception as exc:
+        msg = (
+            "The Python package `mqt.bench` is not installed in the current "
+            "environment. Install it with\n\n"
+            "    pip install mqt.bench\n\n"
+            f"and try again. (Original error: {exc})"
+        )
+        raise MQTBenchExporterError(msg) from None
+
+    lines: list[str] = []
+    lines.extend((
+        f"// Benchmark created by MQT Bench on {date.today()}",
+        "// For more info: https://www.cda.cit.tum.de/mqtbench/",
+        f"// MQT Bench version: {version}",
+        f"// Qiskit version: {__qiskit_version__}",
+        f"// Output format: {fmt.value}",
+    ))
+
+    if gate_set:
+        lines.append(f"// Used gate set: {gate_set}")
+    if c_map:
+        lines.append(f"// Coupling map: {c_map}")
+
+    return "\n".join(lines) + "\n\n"
+
+
+@overload
+def write_circuit(
+    qc: QuantumCircuit,
+    destination: Path,
+    fmt: OutputFormat = OutputFormat.QASM3,
+    gate_set: list[str] | None = None,
+    c_map: list[list[int]] | None = None,
+) -> None:  # pragma: no cover - typing overload only
+    ...
path: src/mqt/bench/output.py
user: login: github-advanced-security[bot]
body: ## Statement has no effect

This statement has no effect.

[Show more details](https://github.com/cda-tum/mqt-bench/security/code-scanning/115)
created_at: 2025-04-23T11:58:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,211 @@
+"""Output functions for the MQT Bench module."""
+
+from __future__ import annotations
+
+from datetime import date
+from enum import Enum
+from importlib import metadata
+from io import TextIOBase
+from pathlib import Path
+from typing import TYPE_CHECKING, TextIO, overload
+
+from qiskit import QuantumCircuit
+from qiskit import __version__ as __qiskit_version__
+from qiskit.qasm2 import dump as dump2
+from qiskit.qasm3 import dump as dump3
+from qiskit.qpy import dump as dump_qpy
+
+if TYPE_CHECKING:  # pragma: no cover
+    from typing import BinaryIO
+
+
+class OutputFormat(str, Enum):
+    """Enumeration of supported output formats for circuit export."""
+
+    QASM2 = "qasm2"
+    QASM3 = "qasm3"
+    QPY = "qpy"
+
+    def extension(self) -> str:
+        """Return the canonical filename extension for this format."""
+        return "qasm" if self in (OutputFormat.QASM2, OutputFormat.QASM3) else self.value
+
+
+class MQTBenchExporterError(Exception):
+    """Custom exception for errors arising during MQT Bench exporting operations."""
+
+
+def _attach_metadata(qc: QuantumCircuit, header: str) -> QuantumCircuit:
+    """Return *a shallow copy* whose ``metadata`` carries the MQT-Bench header."""
+    clone = qc.copy()
+    clone.metadata = (clone.metadata or {}) | {"mqt_bench": header}
+    return clone
+
+
+def generate_header(
+    fmt: OutputFormat,
+    gate_set: list[str] | None = None,
+    c_map: list[list[int]] | None = None,
+) -> str:
+    """Generate a standardized header for MQT Bench outputs.
+
+    Arguments:
+        fmt: The chosen output format enum member
+        gate_set: Optional list of gate names used in the circuit
+        c_map: Optional coupling map of the hardware layout
+
+    Returns:
+        A string containing the formatted header.
+    """
+    try:
+        version = metadata.version("mqt.bench")
+    except Exception as exc:
+        msg = (
+            "The Python package `mqt.bench` is not installed in the current "
+            "environment. Install it with\n\n"
+            "    pip install mqt.bench\n\n"
+            f"and try again. (Original error: {exc})"
+        )
+        raise MQTBenchExporterError(msg) from None
+
+    lines: list[str] = []
+    lines.extend((
+        f"// Benchmark created by MQT Bench on {date.today()}",
+        "// For more info: https://www.cda.cit.tum.de/mqtbench/",
+        f"// MQT Bench version: {version}",
+        f"// Qiskit version: {__qiskit_version__}",
+        f"// Output format: {fmt.value}",
+    ))
+
+    if gate_set:
+        lines.append(f"// Used gate set: {gate_set}")
+    if c_map:
+        lines.append(f"// Coupling map: {c_map}")
+
+    return "\n".join(lines) + "\n\n"
+
+
+@overload
+def write_circuit(
+    qc: QuantumCircuit,
+    destination: Path,
+    fmt: OutputFormat = OutputFormat.QASM3,
+    gate_set: list[str] | None = None,
+    c_map: list[list[int]] | None = None,
+) -> None:  # pragma: no cover - typing overload only
+    ...
+
+
+@overload
+def write_circuit(
+    qc: QuantumCircuit,
+    destination: TextIO | BinaryIO,
+    fmt: OutputFormat = OutputFormat.QASM3,
+    gate_set: list[str] | None = None,
+    c_map: list[list[int]] | None = None,
+) -> None:  # pragma: no cover - typing overload only
+    ...
path: src/mqt/bench/output.py
user: login: github-advanced-security[bot]
body: ## Statement has no effect

This statement has no effect.

[Show more details](https://github.com/cda-tum/mqt-bench/security/code-scanning/116)
created_at: 2025-04-23T11:58:25Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-04-22T09:42:11Z
body: ## [Codecov](https://app.codecov.io/gh/cda-tum/mqt-bench/pull/540?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cda-tum) Report
Attention: Patch coverage is `91.97531%` with `13 lines` in your changes missing coverage. Please review.
> Project coverage is 95.6%. Comparing base [(`4879cf3`)](https://app.codecov.io/gh/cda-tum/mqt-bench/commit/4879cf3f7eecfd69be2eddc34dab1f9b5874d69a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cda-tum) to head [(`e5a2480`)](https://app.codecov.io/gh/cda-tum/mqt-bench/commit/e5a2480e525e3b6d674ce3fc65dfc7d8756f2655?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cda-tum).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/cda-tum/mqt-bench/pull/540?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cda-tum) | Patch % | Lines |
|---|---|---|
| [src/mqt/bench/output.py](https://app.codecov.io/gh/cda-tum/mqt-bench/pull/540?src=pr&el=tree&filepath=src%2Fmqt%2Fbench%2Foutput.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cda-tum#diff-c3JjL21xdC9iZW5jaC9vdXRwdXQucHk=) | 90.6% | [8 Missing :warning: ](https://app.codecov.io/gh/cda-tum/mqt-bench/pull/540?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cda-tum) |
| [src/mqt/bench/cli.py](https://app.codecov.io/gh/cda-tum/mqt-bench/pull/540?src=pr&el=tree&filepath=src%2Fmqt%2Fbench%2Fcli.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cda-tum#diff-c3JjL21xdC9iZW5jaC9jbGkucHk=) | 86.4% | [5 Missing :warning: ](https://app.codecov.io/gh/cda-tum/mqt-bench/pull/540?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cda-tum) |

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


```diff
@@           Coverage Diff           @@
##            main    #540     +/-   ##
=======================================
- Coverage   95.8%   95.6%   -0.3%     
=======================================
  Files         34      34             
  Lines       1463    1488     +25     
=======================================
+ Hits        1403    1423     +20     
- Misses        60      65      +5     
```

| [Flag](https://app.codecov.io/gh/cda-tum/mqt-bench/pull/540/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cda-tum) | Coverage Δ | |
|---|---|---|
| [python](https://app.codecov.io/gh/cda-tum/mqt-bench/pull/540/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cda-tum) | `95.6% <91.9%> (-0.3%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cda-tum#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/cda-tum/mqt-bench/pull/540?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cda-tum).   
: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=cda-tum).

<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>
author_association: NONE
user: login: simon1hofmann
created_at: 2025-04-23T12:01:26Z
body: > Many thanks for all the work on this! I really like where this is going. You'll find detailed feedback in the inline comments. Most likely, this will be good to go after this feedback round.

Thanks a lot for the detailed and helpful review, I tried to include all comments and suggestions.
author_association: COLLABORATOR