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 @@ -27,6 +27,7 @@ Types of changes:
### Removed

### Fixed
- Fixed bare OpenQASM expression statements raising `AttributeError` or `KeyError`. Their values are now evaluated and discarded, and unknown gate errors name the gate using the original source line. ([#388](https://github.com/qBraid/pyqasm/issues/388))
- Fixed Clifford+T rebasing for exact `rx`, `ry`, and `rz` rotations at multiples of π/4. These gates now decompose instead of disappearing, while angles outside the exact basis raise `RebaseError` instead of producing an incorrect result. ([#428](https://github.com/qBraid/pyqasm/issues/428))

### Dependencies
Expand Down
44 changes: 37 additions & 7 deletions src/pyqasm/pulse/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,41 @@ def _visit_function_call( # pylint: disable=too-many-branches, too-many-stateme

return _return_value, [statement]

def _visit_expression_statement(
self, statement: qasm3_ast.ExpressionStatement
) -> list[qasm3_ast.Statement]:
"""Visit an expression statement in an OpenPulse block.

OpenPulse functions retain their specialized validation and output.
Other expressions use the main visitor's evaluator and discard their
value.

Args:
statement (ExpressionStatement): The expression statement to visit.

Returns:
list[Statement]: Statements produced while evaluating the expression.
"""
expression = statement.expression
pulse_functions = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N3 — this list now has to stay in sync with _visit_function_call.

That method special-cases eight names; four of them (get_phase, get_frequency, newframe, play) aren't in any of the three maps, so they're spelled out again here. I checked the current set is complete — set_phase, set_frequency, shift_phase and shift_frequency all come in via OPENPULSE_FRAME_FUNCTION_MAP — so there's no bug today.

But a ninth added to _visit_function_call and not here would silently reroute to the generic evaluator. Existing tests would catch it loudly, so this is maintainability only. Could you pull the four literals into a module-level constant that both sites use, or failing that leave a comment here pointing at _visit_function_call?

*OPENPULSE_FRAME_FUNCTION_MAP,
*OPENPULSE_WAVEFORM_FUNCTION_MAP,
*OPENPULSE_CAPTURE_FUNCTION_MAP,
# Keep these names in sync with the special cases in _visit_function_call.
"get_phase",
"get_frequency",
"newframe",
"play",
}
if (
isinstance(expression, qasm3_ast.FunctionCall)
and expression.name.name in pulse_functions
):
_, statements = self._visit_function_call(expression)
return statements # type: ignore[return-value]
_, statements = Qasm3ExprEvaluator.evaluate_expression(expression)
return statements

def visit_statement(
self, statement: qasm3_ast.Statement | qasm3_ast.Pragma
) -> list[qasm3_ast.Statement]:
Expand All @@ -789,7 +824,7 @@ def visit_statement(
visit_map = {
qasm3_ast.QuantumBarrier: self._visit_barrier,
qasm3_ast.ClassicalDeclaration: self._visit_classical_declaration,
qasm3_ast.ExpressionStatement: lambda x: self._visit_function_call(x.expression),
qasm3_ast.ExpressionStatement: self._visit_expression_statement,
qasm3_ast.DelayInstruction: self._qasm_visitor._visit_delay_statement,
qasm3_ast.ClassicalAssignment: self._visit_classical_assignment,
qasm3_ast.ConstantDeclaration: self._visit_classical_declaration,
Expand All @@ -799,12 +834,7 @@ def visit_statement(
visitor_function = visit_map.get(type(statement))

if visitor_function:
if isinstance(statement, qasm3_ast.ExpressionStatement):
# these return a tuple of return value and list of statements
_, ret_stmts = visitor_function(statement) # type: ignore[operator]
result.extend(ret_stmts)
else:
result.extend(visitor_function(statement)) # type: ignore[operator]
result.extend(visitor_function(statement)) # type: ignore[operator]
else:
if isinstance(statement, qasm3_ast.ReturnStatement):
if statement.expression:
Expand Down
36 changes: 29 additions & 7 deletions src/pyqasm/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ def _construct_visit_map(self):
qasm3_ast.SwitchStatement: self._visit_switch_statement,
qasm3_ast.SubroutineDefinition: self._visit_subroutine_definition,
qasm3_ast.ExternDeclaration: self._visit_subroutine_definition,
qasm3_ast.ExpressionStatement: lambda x: self._visit_function_call(x.expression),
qasm3_ast.ExpressionStatement: self._visit_expression_statement,
qasm3_ast.IODeclaration: lambda x: [],
qasm3_ast.BreakStatement: self._visit_break,
qasm3_ast.ContinueStatement: self._visit_continue,
Expand Down Expand Up @@ -1638,6 +1638,15 @@ def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-man
)
return stmts # type: ignore

if (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain a little why this change is needed here? Just want to understand this through an example

@danielgaskins danielgaskins Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. With unknown missing_qubit;, the old path checks the qubit first, so it reports missing_qubit without ever identifying the invalid gate. This check looks up unknown first and reports the gate name at the original source line. It skips custom and black-box gates, so their handling stays the same. The new tests cover both this case and an unknown gate with a declared qubit.

isinstance(operation, qasm3_ast.QuantumGate)
and operation.name.name not in self._custom_gates
and not self._is_black_box_gate(operation.name.name)
):
# Resolve the operation before its operands so an unknown gate is
# reported even when one of its qubits is also undeclared.
map_qasm_op_to_callable(operation)

self._in_generic_gate_op_scope += 1

# only needs to be done once for a gate operation
Expand Down Expand Up @@ -3522,6 +3531,24 @@ def _visit_include(self, include: qasm3_ast.Include) -> list[qasm3_ast.Statement

return [include]

@staticmethod
def _visit_expression_statement(
statement: qasm3_ast.ExpressionStatement,
) -> list[qasm3_ast.Statement]:
"""Evaluate an expression statement and discard its value.

Statements produced while evaluating the expression are retained so
that calls to user-defined and external functions keep their effects.

Args:
statement (ExpressionStatement): The expression statement to visit.

Returns:
list[Statement]: Statements produced while evaluating the expression.
"""
_, statements = Qasm3ExprEvaluator.evaluate_expression(statement.expression)
return statements

def _visit_end_statement(
self, statement: qasm3_ast.EndStatement
) -> list[qasm3_ast.EndStatement]:
Expand Down Expand Up @@ -3560,12 +3587,7 @@ def visit_statement(

visitor_function = self._visit_map.get(type(statement))
if visitor_function:
if isinstance(statement, qasm3_ast.ExpressionStatement):
# these return a tuple of return value and list of statements
_, ret_stmts = visitor_function(statement) # type: ignore[operator]
result.extend(ret_stmts)
else:
result.extend(visitor_function(statement)) # type: ignore[operator]
result.extend(visitor_function(statement)) # type: ignore[operator]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N2 — #388 also asked for a defensive try/except here, around the top-level dispatch, re-raising AttributeError/TypeError as ValidationError with the statement span — described in the issue as "a cheap safety net beyond this specific fix".

I think you're right to have left it out: a blanket catch at the dispatch would convert genuine internal bugs into validation errors and bury them, which is a bad trade for a net that my fuzzing suggests is already empty. No change requested.

The only issue is that the PR says Closes #388, so that item will silently close as done. Could you either add a line to the description saying it was deliberately declined and why, or drop the auto-close so the item can be judged separately?

else:
raise_qasm3_error(
f"Unsupported statement of type {type(statement)}",
Expand Down
43 changes: 43 additions & 0 deletions tests/qasm3/openpulse/test_general.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,55 @@

"""

import openqasm3.ast as qasm3_ast
import pytest

from pyqasm.entrypoint import loads
from pyqasm.exceptions import ValidationError


@pytest.mark.parametrize("expression", ["1 + 2;", "i;", "sin(1.0);"])
def test_pure_expression_statements_are_discarded(expression: str):
"""Pure expressions in calibration blocks do not emit statements.

Args:
expression (str): The expression statement to evaluate.
"""
module = loads(f"""
OPENQASM 3.0;
defcalgrammar "openpulse";
cal {{
int i = 1;
{expression}
}}
""")

module.validate()
module.unroll()

calibration = next(
statement
for statement in module.unrolled_ast.statements
if isinstance(statement, qasm3_ast.CalibrationStatement)
)
assert [line.strip() for line in calibration.body.splitlines() if line.strip()] == [
"int i = 1;"
]


@pytest.mark.parametrize("operation", ["validate", "unroll"])
def test_unknown_expression_statement_call_raises_validation_error(operation: str):
"""Unknown calls in calibration blocks use the public error type.

Args:
operation (str): The module method to call.
"""
module = loads('OPENQASM 3.0; defcalgrammar "openpulse"; cal { unknown(); }')

with pytest.raises(ValidationError, match="Undefined subroutine 'unknown'"):
getattr(module, operation)()


@pytest.mark.parametrize(
"qasm_code,error_message,error_span",
[
Expand Down
2 changes: 1 addition & 1 deletion tests/qasm3/resources/gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ def test_fixture():
"Unsupported / undeclared QASM operation: custom_gate",
6,
8,
"custom_gate q1[0], q1[1];", # expanded line
"custom_gate q1;",
),
"parameter_mismatch_1": (
"""
Expand Down
100 changes: 100 additions & 0 deletions tests/qasm3/test_expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

"""

import openqasm3.ast as qasm3_ast
import pytest

from pyqasm.entrypoint import loads
Expand Down Expand Up @@ -105,3 +106,102 @@ def test_incorrect_expressions(caplog):
loads("OPENQASM 3; qubit q; int x; rx(x) q;").validate()
assert "Error at line 1" in caplog.text
assert "x" in caplog.text


@pytest.mark.parametrize(
"expression",
[
"1;",
"value;",
"value + 2;",
"-value;",
"values[0];",
"sin(1.0);",
],
)
def test_expression_statements_are_evaluated_and_discarded(expression: str):
"""Pure expression statements are valid but do not emit operations.

Args:
expression (str): The expression statement to evaluate.
"""
module = loads(f"""
OPENQASM 3.0;
include "stdgates.inc";
int value = 1;
array[int[32], 2] values = {{1, 2}};
qubit q;
{expression}
x q;
""")

module.validate()
module.unroll()

assert not any(
isinstance(statement, qasm3_ast.ExpressionStatement)
for statement in module.unrolled_ast.statements
)
check_single_qubit_gate_op(module.unrolled_ast, 1, [0], "x")


def test_subroutine_expression_statement_retains_operations():
"""Statements produced by evaluating a subroutine call are retained."""
module = loads("""
OPENQASM 3.0;
include "stdgates.inc";
def apply_x(qubit target) {
x target;
}
qubit q;
apply_x(q);
""")

module.validate()
module.unroll()

check_single_qubit_gate_op(module.unrolled_ast, 1, [0], "x")


@pytest.mark.parametrize("operation", ["validate", "unroll"])
def test_unknown_expression_statement_call_raises_validation_error(operation: str):
"""Unknown calls in expression statements use the public error type.

Args:
operation (str): The module method to call.
"""
module = loads("OPENQASM 3.0; unknown();")

with pytest.raises(ValidationError, match="Undefined subroutine 'unknown'"):
getattr(module, operation)()


@pytest.mark.parametrize(
"source,error",
[
("OPENQASM 3.0; unknown;", "Undefined identifier 'unknown'"),
(
"OPENQASM 3.0; unknown missing_qubit;",
"Unsupported / undeclared QASM operation: unknown",
),
(
"OPENQASM 3.0; qubit q; unknown q;",
"Unsupported / undeclared QASM operation: unknown",
),
],
)
@pytest.mark.parametrize("operation", ["validate", "unroll"])
def test_unknown_gate_reports_its_name_before_checking_operands(
source: str, error: str, operation: str
):
"""Unknown gate names are reported even when an operand is undeclared.

Args:
source (str): The OpenQASM program to validate or unroll.
error (str): The expected error message.
operation (str): The module method to call.
"""
module = loads(source)

with pytest.raises(ValidationError, match=error):
getattr(module, operation)()
Loading