Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Types of changes:
### Added
- Added support for OpenQASM 2 `opaque` declarations, which previously failed at parse time and blocked vendor include files such as Quantinuum's `hqslib1.inc`. An opaque gate is treated as a black box: emitted as written, counted as one layer of depth. `to_qasm3()` rejects such a program. ([#370](https://github.com/qBraid/pyqasm/issues/370))
- Added an `include_dir` kwarg to `loads()` and `load()`, naming the directory custom `include` statements resolve against. A program given as a string could not resolve includes at all, and failed later naming the gate rather than the include. Resolution is opt-in: without the kwarg, no files are read. ([#368](https://github.com/qBraid/pyqasm/issues/368))
- Added a `compact_gate_arguments` setting, passed to `loads()` or set on the module, which prints gate arguments without spaces around `*`, `/` and `**`: `rx(pi/2)` instead of `rx(pi / 2)`. Vendors such as Diraq match rotation angles textually and reject the spaced form. ([#427](https://github.com/qBraid/pyqasm/pull/427))

### Improved / Modified

Expand Down
5 changes: 5 additions & 0 deletions src/pyqasm/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"frame_in_def_cal": "_frame_in_def_cal",
"frame_limit_per_port": "_frame_limit_per_port",
"play_in_cal_block": "_play_in_cal",
"compact_gate_arguments": "_compact_gate_arguments",
}

# kwargs consumed by the entrypoint itself rather than stored on the module
Expand Down Expand Up @@ -145,6 +146,10 @@ def loads(program: openqasm3.ast.Program | str, **kwargs) -> QasmModule:

- **play_in_cal_block** (bool): Whether to allow play in defcal.

- **compact_gate_arguments** (bool): Print gate arguments without spaces around
'*', '/' and '**': ``rx(pi/2)`` instead of ``rx(pi / 2)``. Defaults to False.
Also settable later through ``module.compact_gate_arguments``.

- **include_dir** (str): Directory holding the program's custom include files.
A program given as a string has no filesystem location of its own, so this
is the only way to resolve its includes. Omit it and custom includes are
Expand Down
15 changes: 15 additions & 0 deletions src/pyqasm/modules/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ def __init__(self, name: str, program: Program):
self._frame_in_def_cal: Optional[bool] = True
self._frame_limit_per_port: Optional[int] = None
self._play_in_cal: Optional[bool] = True
self._compact_gate_arguments: bool = False

@property
def name(self) -> str:
Expand Down Expand Up @@ -267,6 +268,20 @@ def _add_classical_register(self, reg_name: str, num_clbits: int) -> None:
self._classical_registers[reg_name] = num_clbits
self._num_clbits += num_clbits

@property
def compact_gate_arguments(self) -> bool:
"""Whether printing drops the spaces around '*', '/' and '**' in gate arguments.

When true, the module prints ``rx(pi/2)`` instead of ``rx(pi / 2)``. Some vendors
(e.g. Diraq) match rotation angles textually and only accept the compact spelling.
"""
return self._compact_gate_arguments

@compact_gate_arguments.setter
def compact_gate_arguments(self, value: bool) -> None:
"""Set whether printing drops the spaces around '*', '/' and '**' in gate arguments."""
self._compact_gate_arguments = value

@property
def original_program(self) -> Program:
"""Returns the program AST for the original qasm supplied by the user."""
Expand Down
13 changes: 9 additions & 4 deletions src/pyqasm/modules/qasm2.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,10 @@

import openqasm3.ast as qasm3_ast
from openqasm3.ast import Include, Program
from openqasm3.printer import dumps

from pyqasm.exceptions import ValidationError, raise_qasm3_error
from pyqasm.modules.base import QasmModule, QasmVisitor
from pyqasm.modules.qasm3 import Qasm3Module
from pyqasm.modules.qasm3 import Qasm3Module, dumps

# the QASM 2.0 <qop> production: a gate application, a measurement or a reset.
# only these may be the body of an 'if'.
Expand Down Expand Up @@ -125,7 +124,9 @@ def _qasm_ast_to_str(self, qasm_ast: Program) -> str:
"""Convert the qasm AST to a string."""
# set the version to 2.0
qasm_ast.version = "2.0"
raw_qasm = dumps(qasm_ast, old_measurement=True)
raw_qasm = dumps(
qasm_ast, old_measurement=True, compact_gate_arguments=self._compact_gate_arguments
)
return self._format_declarations(raw_qasm)

def to_qasm3(self, as_str: bool = False) -> str | Qasm3Module:
Expand Down Expand Up @@ -156,7 +157,11 @@ def to_qasm3(self, as_str: bool = False) -> str | Qasm3Module:
stmt.filename = "stdgates.inc"
break
qasm_program.version = "3.0"
return dumps(qasm_program) if as_str else Qasm3Module(self._name, qasm_program)
if as_str:
return dumps(qasm_program, compact_gate_arguments=self._compact_gate_arguments)
module = Qasm3Module(self._name, qasm_program)
module.compact_gate_arguments = self._compact_gate_arguments
return module

def finalize(self, statements: list[qasm3_ast.Statement]) -> list[qasm3_ast.Statement]:
"""Apply the QASM 2 transformations the finalized statement list needs.
Expand Down
85 changes: 79 additions & 6 deletions src/pyqasm/modules/qasm3.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,93 @@
import io
from typing import Any

from openqasm3.ast import Pragma, Program, QASMNode
from openqasm3 import properties
from openqasm3.ast import (
BinaryExpression,
BinaryOperator,
Pragma,
Program,
QASMNode,
QuantumGate,
QuantumPhase,
)
from openqasm3.printer import Printer, PrinterState

from pyqasm.modules.base import QasmModule, QasmVisitor

_COMPACT_OPERATORS = frozenset({BinaryOperator["*"], BinaryOperator["/"], BinaryOperator["**"]})


class Qasm3Printer(Printer):
"""OpenQASM 3 printer that writes pragmas in their '#pragma' form.
"""OpenQASM 3 printer with two changes to the upstream printer.

Pragmas are written in their '#pragma' form. The upstream printer emits the bare
'pragma' keyword. Both forms parse, but tools consuming the output (e.g. Amazon
Braket for '#pragma braket verbatim') expect the hashed form.

The upstream printer emits the bare 'pragma' keyword. Both forms parse, but tools
consuming the output (e.g. Amazon Braket for '#pragma braket verbatim') expect the
hashed form, which is also what they emit.
With ``compact_gate_arguments``, gate arguments are written without spaces around
'*', '/' and '**': ``rx(pi/2)`` instead of ``rx(pi / 2)``. Some vendors (e.g. Diraq)
match rotation angles textually and only accept the compact spelling.
"""

def __init__(
self, stream: io.TextIOBase, *, compact_gate_arguments: bool = False, **kwargs: Any
) -> None:
"""Create a printer.

Args:
stream (io.TextIOBase): The stream to write to.
compact_gate_arguments (bool): Drop the spaces around '*', '/' and '**' in gate
arguments. Defaults to False.
**kwargs (Any): Printer options, forwarded to `openqasm3.printer.Printer`.
"""
super().__init__(stream, **kwargs)
self.compact_gate_arguments = compact_gate_arguments
# true only while a gate call prints, so classical expressions keep their spacing
self._printing_gate_expr = False

def visit_QuantumGate(self, node: QuantumGate, context: PrinterState) -> None:
"""Write a gate call, marking its expressions as gate arguments."""
self._printing_gate_expr = True
try:
super().visit_QuantumGate(node, context)
finally:
self._printing_gate_expr = False

def visit_QuantumPhase(self, node: QuantumPhase, context: PrinterState) -> None:
"""Write a gphase call, marking its expressions as gate arguments."""
self._printing_gate_expr = True
try:
super().visit_QuantumPhase(node, context)
finally:
self._printing_gate_expr = False

def visit_BinaryExpression(self, node: BinaryExpression, context: PrinterState) -> None:
"""Write a binary expression, compacting the operator inside gate arguments.

Mirrors the upstream method, which hardcodes the spaces around the operator.
"""
our_precedence = properties.precedence(node)
# All AST nodes that are built into BinaryExpression are currently left associative.
if properties.precedence(node.lhs) < our_precedence:
self.stream.write("(")
self.visit(node.lhs, context)
self.stream.write(")")
else:
self.visit(node.lhs, context)
compact = (
self.compact_gate_arguments
and self._printing_gate_expr
and node.op in _COMPACT_OPERATORS
)
self.stream.write(node.op.name if compact else f" {node.op.name} ")
if properties.precedence(node.rhs) <= our_precedence:
self.stream.write("(")
self.visit(node.rhs, context)
self.stream.write(")")
else:
self.visit(node.rhs, context)

def visit_Pragma(self, node: Pragma, context: PrinterState) -> None:
"""Write a pragma node, keeping the '#' that the upstream printer drops.

Expand Down Expand Up @@ -78,7 +151,7 @@ def _qasm_ast_to_str(self, qasm_ast: Program) -> str:
"""Convert the qasm AST to a string."""
# set the version to 3.0
qasm_ast.version = "3.0"
return dumps(qasm_ast)
return dumps(qasm_ast, compact_gate_arguments=self._compact_gate_arguments)

def accept(self, visitor: QasmVisitor) -> None:
"""Accept a visitor for the module.
Expand Down
85 changes: 85 additions & 0 deletions tests/qasm3/test_printer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Copyright 2025 qBraid
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Module containing unit tests for the Qasm3Printer options.
"""

import pytest

from pyqasm.entrypoint import dumps, loads
from tests.utils import check_unrolled_qasm

QASM3_HEADER = 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[2] q;\n'


@pytest.mark.parametrize(
("statement", "expected"),
[
("rx(pi / 2) q[0];", "rx(pi/2) q[0];"),
("rx(pi * 0.5) q[0];", "rx(pi*0.5) q[0];"),
("rz(2 * pi) q[0];", "rz(2*pi) q[0];"),
("rx(2 ** 3) q[0];", "rx(2**3) q[0];"),
("rx(pi / 2 + 1) q[0];", "rx(pi/2 + 1) q[0];"),
("rx(pi - 1) q[0];", "rx(pi - 1) q[0];"),
("rx(pi / (2 * pi)) q[0];", "rx(pi/(2*pi)) q[0];"),
("rx((pi + 1) / 2) q[0];", "rx((pi + 1)/2) q[0];"),
("ctrl @ rx(pi / 2) q[0], q[1];", "ctrl @ rx(pi/2) q[0], q[1];"),
("gphase(pi / 2);", "gphase(pi/2);"),
],
)
def test_compact_gate_arguments(statement, expected):
module = loads(QASM3_HEADER + statement, compact_gate_arguments=True)
check_unrolled_qasm(dumps(module), QASM3_HEADER + expected)
check_unrolled_qasm(str(module), QASM3_HEADER + expected)


def test_compact_gate_arguments_off_by_default():
qasm = QASM3_HEADER + "rx(pi / 2) q[0];"
module = loads(qasm)
assert module.compact_gate_arguments is False
check_unrolled_qasm(dumps(module), qasm)


def test_compact_gate_arguments_set_after_load():
qasm = QASM3_HEADER + "rx(pi / 2) q[0];"
module = loads(qasm)
module.compact_gate_arguments = True
check_unrolled_qasm(dumps(module), QASM3_HEADER + "rx(pi/2) q[0];")
module.compact_gate_arguments = False
check_unrolled_qasm(dumps(module), qasm)


def test_compact_gate_arguments_survives_unroll_and_copy():
module = loads(QASM3_HEADER + "rx(pi / 2) q[0];\nh q[1];", compact_gate_arguments=True)
module.unroll()
assert module.compact_gate_arguments is True
copied = module.copy()
assert copied.compact_gate_arguments is True
assert dumps(copied) == dumps(module)


def test_compact_gate_arguments_keeps_classical_spacing():
qasm = QASM3_HEADER + "int[32] a = 3 * 4;\nrx(pi / 2) q[0];\n"
expected = QASM3_HEADER + "int[32] a = 3 * 4;\nrx(pi/2) q[0];\n"
check_unrolled_qasm(dumps(loads(qasm, compact_gate_arguments=True)), expected)


def test_compact_gate_arguments_qasm2():
header = 'OPENQASM 2.0;\ninclude "qelib1.inc";\nqreg q[1];\n'
module = loads(header + "rx(pi / 2) q[0];\n", compact_gate_arguments=True)
check_unrolled_qasm(dumps(module), header + "rx(pi/2) q[0];")
qasm3_header = 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[1] q;\n'
check_unrolled_qasm(module.to_qasm3(as_str=True), qasm3_header + "rx(pi/2) q[0];")
assert module.to_qasm3().compact_gate_arguments is True
Loading