From e90f80bc3c760019164f9346f37fbf43857d7e60 Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Wed, 16 Sep 2026 17:12:19 +0530 Subject: [PATCH 1/3] feat: add compact_gate_arguments module setting to the printer Print `*`, `/` and `**` without spaces inside gate calls, so `rx(pi / 2)` prints as `rx(pi/2)`. Set it through `loads()` or `module.compact_gate_arguments`. The default is off, so existing output does not change. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + src/pyqasm/entrypoint.py | 5 +++ src/pyqasm/modules/base.py | 14 +++++++ src/pyqasm/modules/qasm2.py | 13 ++++-- src/pyqasm/modules/qasm3.py | 73 +++++++++++++++++++++++++++++--- tests/qasm3/test_printer.py | 84 +++++++++++++++++++++++++++++++++++++ 6 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 tests/qasm3/test_printer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 110765bb..f71c229d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. ### Improved / Modified diff --git a/src/pyqasm/entrypoint.py b/src/pyqasm/entrypoint.py index 83d0b296..00aaf2db 100644 --- a/src/pyqasm/entrypoint.py +++ b/src/pyqasm/entrypoint.py @@ -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 @@ -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 diff --git a/src/pyqasm/modules/base.py b/src/pyqasm/modules/base.py index a28b6184..96f2d03a 100644 --- a/src/pyqasm/modules/base.py +++ b/src/pyqasm/modules/base.py @@ -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: @@ -267,6 +268,19 @@ 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: + self._compact_gate_arguments = value + @property def original_program(self) -> Program: """Returns the program AST for the original qasm supplied by the user.""" diff --git a/src/pyqasm/modules/qasm2.py b/src/pyqasm/modules/qasm2.py index 26604ace..7717a345 100644 --- a/src/pyqasm/modules/qasm2.py +++ b/src/pyqasm/modules/qasm2.py @@ -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 production: a gate application, a measurement or a reset. # only these may be the body of an 'if'. @@ -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: @@ -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. diff --git a/src/pyqasm/modules/qasm3.py b/src/pyqasm/modules/qasm3.py index 23b9d1fd..b6cb513a 100644 --- a/src/pyqasm/modules/qasm3.py +++ b/src/pyqasm/modules/qasm3.py @@ -19,20 +19,81 @@ 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): + 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: + 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: + 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. @@ -78,7 +139,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. diff --git a/tests/qasm3/test_printer.py b/tests/qasm3/test_printer.py new file mode 100644 index 00000000..cff92d8f --- /dev/null +++ b/tests/qasm3/test_printer.py @@ -0,0 +1,84 @@ +# 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];"), + ("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 From b82dd3b30943c87461cc2b0439ff573ac63fac05 Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Wed, 16 Sep 2026 17:12:47 +0530 Subject: [PATCH 2/3] docs: link PR #427 in the changelog Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f71c229d..3207efa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +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. +- 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 From 83127ad7749afaf383a27337d573c0165a30d254 Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Wed, 16 Sep 2026 17:22:41 +0530 Subject: [PATCH 3/3] docs: document and type the printer overrides; cover left-operand parentheses Co-Authored-By: Claude Opus 5 (1M context) --- src/pyqasm/modules/base.py | 1 + src/pyqasm/modules/qasm3.py | 14 +++++++++++++- tests/qasm3/test_printer.py | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/pyqasm/modules/base.py b/src/pyqasm/modules/base.py index 96f2d03a..cafd509f 100644 --- a/src/pyqasm/modules/base.py +++ b/src/pyqasm/modules/base.py @@ -279,6 +279,7 @@ def compact_gate_arguments(self) -> bool: @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 diff --git a/src/pyqasm/modules/qasm3.py b/src/pyqasm/modules/qasm3.py index b6cb513a..f9bb0b59 100644 --- a/src/pyqasm/modules/qasm3.py +++ b/src/pyqasm/modules/qasm3.py @@ -48,13 +48,24 @@ class Qasm3Printer(Printer): match rotation angles textually and only accept the compact spelling. """ - def __init__(self, stream: io.TextIOBase, *, compact_gate_arguments: bool = False, **kwargs): + 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) @@ -62,6 +73,7 @@ def visit_QuantumGate(self, node: QuantumGate, context: PrinterState) -> None: 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) diff --git a/tests/qasm3/test_printer.py b/tests/qasm3/test_printer.py index cff92d8f..a8b6f1e5 100644 --- a/tests/qasm3/test_printer.py +++ b/tests/qasm3/test_printer.py @@ -34,6 +34,7 @@ ("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);"), ],