diff --git a/CHANGELOG.md b/CHANGELOG.md index e89c7144..10c65cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/pyqasm/pulse/visitor.py b/src/pyqasm/pulse/visitor.py index 6128aa69..a5da182a 100644 --- a/src/pyqasm/pulse/visitor.py +++ b/src/pyqasm/pulse/visitor.py @@ -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 = { + *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]: @@ -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, @@ -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: diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index a1afb6f7..bd9ec999 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -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, @@ -1638,6 +1638,15 @@ def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-man ) return stmts # type: ignore + if ( + 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 @@ -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]: @@ -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] else: raise_qasm3_error( f"Unsupported statement of type {type(statement)}", diff --git a/tests/qasm3/openpulse/test_general.py b/tests/qasm3/openpulse/test_general.py index 2d06a116..978f171d 100644 --- a/tests/qasm3/openpulse/test_general.py +++ b/tests/qasm3/openpulse/test_general.py @@ -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", [ diff --git a/tests/qasm3/resources/gates.py b/tests/qasm3/resources/gates.py index 73fd6565..626b2a00 100644 --- a/tests/qasm3/resources/gates.py +++ b/tests/qasm3/resources/gates.py @@ -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": ( """ diff --git a/tests/qasm3/test_expressions.py b/tests/qasm3/test_expressions.py index ef549f51..b0c9cb04 100644 --- a/tests/qasm3/test_expressions.py +++ b/tests/qasm3/test_expressions.py @@ -17,6 +17,7 @@ """ +import openqasm3.ast as qasm3_ast import pytest from pyqasm.entrypoint import loads @@ -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)()