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

### 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))

### Improved / Modified
Expand Down
29 changes: 29 additions & 0 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,35 @@ Source code for OpenQASM 3 program validator and semantic analyzer
| ComplexType | ✅ | Completed |
| AngleType | ✅ | Completed |
| ExternDeclaration | ✅ | Completed |
| opaque (OpenQASM 2 only) | ✅ | Emitted as written |

## Opaque gates

`opaque NAME(params) qubits;` is OpenQASM 2 syntax that was removed in OpenQASM 3. It
declares a hardware primitive: a gate with a name and an arity and no decomposition.
Quantinuum's `hqslib1.inc` opens with six of them.

pyqasm rewrites the declaration before parsing and records the name, so `validate()`,
`depth()`, `has_measurements()` and the qubit-renumbering passes all work. A call to an
opaque gate is emitted as written rather than unrolled, and counts as one layer of depth,
exactly as an external gate does. `unroll()` never drops that treatment: unlike
`external_gates`, which the caller sets per call, an opaque gate is a property of the
program and has no decomposition to fall back on.

Two things to know:

- **The declaration is not re-emitted.** Unrolling drops it, the same way it drops the
`gate` definition of an external gate, so the output carries calls to a gate it does
not declare and does not load back into pyqasm on its own.
- **`opaque` in an OpenQASM 3 program is still a parse error.** The rewrite is gated on
the `OPENQASM 2` header, because the keyword is not OpenQASM 3 syntax.
- **`rebase()` reports it as unsupported.** An opaque primitive has no decomposition, so
it cannot be rewritten onto a standard basis set; it reaches the existing
unsupported-gate path and is named there.
- **`to_qasm3()` refuses a program that declares one.** OpenQASM 3 removed `opaque` and
has no equivalent for a gate with no decomposition, and a body-less `gate` in
OpenQASM 3 means the identity — so converting would silently turn each hardware
primitive into a no-op.

## Pragmas

Expand Down
10 changes: 9 additions & 1 deletion src/pyqasm/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@
from pyqasm.exceptions import ValidationError
from pyqasm.maps import SUPPORTED_QASM_VERSIONS
from pyqasm.modules import Qasm2Module, Qasm3Module, QasmModule
from pyqasm.preprocess import process_include_sources, process_include_statements
from pyqasm.preprocess import (
process_include_sources,
process_include_statements,
rewrite_opaque_declarations,
)

if TYPE_CHECKING:
import openqasm3.ast
Expand Down Expand Up @@ -165,9 +169,12 @@ def loads(program: openqasm3.ast.Program | str, **kwargs) -> QasmModule:
"""
_validate_kwargs(kwargs)
include_dir = kwargs.pop("include_dir", None)
opaque_gates: set[str] = set()
if isinstance(program, str):
if include_dir is not None:
program = process_include_sources(program, include_dir)
# after include resolution, so an opaque in a vendor include is rewritten too
program, opaque_gates = rewrite_opaque_declarations(program)
try:
program = openqasm3.parse(program)
except openqasm3.parser.QASM3ParsingError as err:
Expand All @@ -191,6 +198,7 @@ def loads(program: openqasm3.ast.Program | str, **kwargs) -> QasmModule:

qasm_module = Qasm3Module if program.version.startswith("3") else Qasm2Module
module = qasm_module("main", program)
module._opaque_gates = opaque_gates
# `is not None`, not truthiness: a falsy value is a caller value, not an omission.
# An explicit None means "not passed", so defaults like extern_functions={} and
# frame_in_def_cal=True are never clobbered.
Expand Down
2 changes: 2 additions & 0 deletions src/pyqasm/modules/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ def __init__(self, name: str, program: Program):
self._validated_program = False
self._unrolled_ast = Program(statements=[])
self._external_gates: list[str] = []
# declared `opaque` in the source, so unroll() never flushes these
self._opaque_gates: set[str] = set()
self._decompose_native_gates: Optional[bool] = None
self._device_qubits: Optional[int] = None
self._consolidate_qubits: Optional[bool] = False
Expand Down
11 changes: 11 additions & 0 deletions src/pyqasm/modules/qasm2.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,20 @@ def to_qasm3(self, as_str: bool = False) -> str | Qasm3Module:
as_str (bool): Flag to indicate if the conversion should be to a string
or to a Qasm3Module object. Default is False.

Raises:
ValidationError: If the program declares an `opaque` gate. OpenQASM 3 removed
`opaque` and has no equivalent, so there is no correct translation.

Returns:
str | Qasm3Module: The module in openqasm3 format.
"""
if self._opaque_gates:
# OpenQASM 3 removed `opaque` from the language
raise ValidationError(
"Cannot convert to OpenQASM 3: the program declares the opaque gate(s) "
f"{', '.join(sorted(self._opaque_gates))}. OpenQASM 3 removed 'opaque' and "
"has no equivalent for a gate with no decomposition."
)
qasm_program = deepcopy(self._original_program)
# replace the include with stdgates.inc
for stmt in qasm_program.statements:
Expand Down
65 changes: 65 additions & 0 deletions src/pyqasm/preprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,74 @@ class IncludeContext:
r'^\s*include\s+"(?:stdgates\.inc|qelib1\.inc)";\s*', re.MULTILINE
),
"include": re.compile(r'^\s*include\s+"([^"]+)";\s*', re.MULTILINE),
# OPENQASM 2 only. Captures indent, name, the parenthesised parameter list if any,
# and the qubit list: "opaque Rz(lam) q;", "opaque ZZ() q1,q2;", "opaque zz q1,q2;"
"opaque": re.compile(
r"^([ \t]*)opaque\s+([A-Za-z_][A-Za-z0-9_]*)\s*(\([^)]*\))?\s*([^;{}]*?)\s*;",
re.MULTILINE,
),
"openqasm2": re.compile(r"^\s*OPENQASM\s+2(?:\.\d+)?;", re.MULTILINE),
# a string literal, a `//` line comment, or a `/* */` block comment (unterminated
# one included). The string alternative comes first so a `//` inside an include path
# is consumed as a string and never mistaken for a comment.
"string_or_comment": re.compile(r'"[^"\n]*"|//[^\n]*|/\*.*?(?:\*/|\Z)', re.DOTALL),
}


def _blank_comments(program: str) -> str:
"""Overwrite `//` and `/* */` comments with spaces.

Blanked rather than deleted so line and column numbers are unchanged: the parser
reports spans against this text, and deleting a comment would shift every position
after it.

Args:
program (str): The OpenQASM program text.

Returns:
str: The text with comment characters replaced by spaces.
"""

def blank(match: re.Match) -> str:
text = match.group()
# keep string literals as they are; only comments are blanked
return text if text.startswith('"') else re.sub(r"[^\n]", " ", text)

return PATTERNS["string_or_comment"].sub(blank, program)


def rewrite_opaque_declarations(program: str) -> tuple[str, set[str]]:
"""Rewrite OpenQASM 2 ``opaque`` declarations into gate definitions with empty bodies.

The ``openqasm3`` parser pyqasm routes qasm2 through has no production for ``opaque``,
so this runs before parsing. The empty body carries the gate's name and arity only,
which is all an opaque gate has (issue #370). Gated on the qasm2 header, since
``opaque`` is not OpenQASM 3 syntax.

Comments are blanked first, so a declaration commented out with ``/* */`` is neither
rewritten nor recorded. The result goes straight to the parser, which discards
comments anyway.

Args:
program (str): The OpenQASM program text.

Returns:
tuple[str, set[str]]: The rewritten program, and the names declared opaque.
"""
code = _blank_comments(program)
if not PATTERNS["openqasm2"].search(code):
return program, set()

names: set[str] = set()

def _replace(match: re.Match) -> str:
indent, name, params, qubits = match.groups()
names.add(name)
return f"{indent}gate {name}{params or ''} {qubits} {{ }}"

return PATTERNS["opaque"].sub(_replace, code), names


def process_include_statements(filename: str, include_dir: str | None = None) -> str:
"""
Recursively processes include statements in an OpenQASM file, replacing them with the
Expand Down
29 changes: 27 additions & 2 deletions src/pyqasm/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ def __init__( # pylint: disable=too-many-arguments
# cycle of any length between gate definitions (issue #369)
self._gate_expansion_chain: list[str] = []
self._external_gates: list[str] = [] if external_gates is None else external_gates
# opaque gates come from the program itself, so they are read off the module
# rather than taken from unroll()'s kwargs (issue #370)
self._opaque_gates: set[str] = getattr(module, "_opaque_gates", set())
self._subroutine_defns: dict[
str, qasm3_ast.SubroutineDefinition | qasm3_ast.ExternDeclaration
] = {}
Expand Down Expand Up @@ -1287,6 +1290,24 @@ def _visit_continue(self, statement: qasm3_ast.ContinueStatement) -> None:
error_node=statement,
)

def _is_black_box_gate(self, gate_name: str) -> bool:
"""Check whether a gate must be emitted as written instead of being unrolled.

True for a gate the caller named in ``external_gates``, any gate inside a verbatim
box, and one the program declared ``opaque`` (issue #370).

Args:
gate_name (str): The name of the gate being applied.

Returns:
bool: True if the gate is emitted as written.
"""
return (
self._in_verbatim_box
or gate_name in self._external_gates
or gate_name in self._opaque_gates
)

def _visit_custom_gate_operation(
self,
operation: qasm3_ast.QuantumGate,
Expand Down Expand Up @@ -1344,7 +1365,11 @@ def _visit_custom_gate_operation(
# 'or', not '=': a nested gate must stay suppressed inside an enclosing external
# gate rather than re-enable recording for the body its parent skips (issue #367)
prev_recording = self._recording_ext_gate_depth
is_external = self._in_verbatim_box or gate_name in self._external_gates
# 'or', not '=': a nested gate must stay suppressed inside an enclosing external
# gate rather than re-enable recording for the body its parent skips (issue #367)
prev_recording = self._recording_ext_gate_depth
is_external = self._is_black_box_gate(gate_name)
self._recording_ext_gate_depth = prev_recording or is_external
self._recording_ext_gate_depth = prev_recording or is_external

result = []
Expand Down Expand Up @@ -1703,7 +1728,7 @@ def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-man
for _ in range(power_value):
if isinstance(operation, qasm3_ast.QuantumPhase):
result.extend(self._visit_phase_operation(operation, inverse_value, ctrls))
elif self._in_verbatim_box or operation.name.name in self._external_gates:
elif self._is_black_box_gate(operation.name.name):
result.extend(self._visit_external_gate_operation(operation, inverse_value, ctrls))
elif operation.name.name in self._custom_gates:
result.extend(self._visit_custom_gate_operation(operation, inverse_value, ctrls))
Expand Down
Loading
Loading