Repository: qBraid/pyqasm

Issue:
Optimize extracting the qasm version
This is already noted as a todo, but based on the [QASM specifications](https://openqasm.com/language/comments.html#version-string), it should be possible to read until the first non-comment line to determine the QASM versions (if one is specified). 

https://github.com/qBraid/pyqasm/blob/0e1cd9f537b074b993cb1d0e5a60cb65658123c7/src/pyqasm/analyzer.py#L227-L228

For large qasm files, parsing the entire file is a substantial overhead. This came up in benchmarking [`ucc`](https://github.com/unitaryfund/ucc) for [this](https://github.com/unitaryfund/ucc/blob/main/benchmarks/qasm_circuits/qasm2/benchpress/qv_N100_12345_basis_rz_rx_ry_cx.qasm) circuit. `ucc` uses `qbraid` to translate circuits between formats.

For this example,  profiling shows of 2m3s script runtime, 2m2s are effectively spent extracting the version, and actually takes two passes, first when checking the input format and again when determining the best path to conversion. It ultimately dispatches to qiskit's native parser for qasm, which runs in under 1 second (rust based?).

```python
from qbraid.transpiler import transpile
# download https://raw.githubusercontent.com/unitaryfund/ucc/refs/heads/main/benchmarks/qasm_circuits/qasm2/benchpress/qv_N100_12345_basis_rz_rx_ry_cx.qasm
qasm = open(
    "qv_N100_12345_basis_rz_rx_ry_cx.qasm",
    "r",
).read()

transpile(qasm, "qiskit")
```

Hints from issue comments:
@ryanhill1: Thanks for pointing this out @bachase, easy fix! Will make another PyQASM release early next week with the update, and will have it integrated into a new release of the qBraid-SDK shortly after.@bachase: Awesome.  Thanks for the quick turnaround!

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7470c32..e00bcb6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,172 +15,23 @@ Types of changes:
 ## Unreleased
 
 ### Added
-- Added support for classical declarations with measurement ([#120](https://github.com/qBraid/pyqasm/pull/120)). Usage example -
-
-```python
-In [1]: from pyqasm import loads, dumps
-
-In [2]: module = loads(
-   ...: """OPENQASM 3.0;
-   ...: qubit q;
-   ...: bit b = measure q;
-   ...: """)
-
-In [3]: module.unroll()
-
-In [4]: dumps(module).splitlines()
-Out[4]: ['OPENQASM 3.0;', 'qubit[1] q;', 'bit[1] b;', 'b[0] = measure q[0];']
-```
-
-- Added support for `gphase`, `toffoli`, `not`, `c3sx` and `c4x` gates ([#86](https://github.com/qBraid/pyqasm/pull/86))
-- Added a `remove_includes` method to `QasmModule` to remove include statements from the generated QASM code ([#100](https://github.com/qBraid/pyqasm/pull/100)). Usage example - 
-
-```python
-In [1]: from pyqasm import loads
-
-In [2]: module = loads(
-   ...: """OPENQASM 3.0;
-   ...: include "stdgates.inc";
-   ...: include "random.qasm";
-   ...: 
-   ...: qubit[2] q;
-   ...: h q;
-   ...: """)
-
-In [3]: module.remove_includes()
-Out[3]: <pyqasm.modules.qasm3.Qasm3Module at 0x10442b190>
-
-In [4]: from pyqasm import dumps
-
-In [5]: dumps(module).splitlines()
-Out[5]: ['OPENQASM 3.0;', 'qubit[2] q;', 'h q;']
-```
-- Added support for unrolling multi-bit branching with `==`, `>=`, `<=`, `>`, and `<` ([#112](https://github.com/qBraid/pyqasm/pull/112)). Usage example -
-```python
-In [1]: from pyqasm import loads
-
-In [2]: module = loads(
-   ...: """OPENQASM 3.0;
-   ...: include "stdgates.inc";
-   ...: qubit[1] q;
-   ...: bit[4] c;
-   ...: if(c == 3){
-   ...:     h q[0];
-   ...: }
-   ...: """)
-
-In [3]: module.unroll()
-
-In [4]: dumps(module)
-OPENQASM 3.0;
-include "stdgates.inc";
-qubit[1] q;
-bit[4] c;
-if (c[0] == false) {
-  if (c[1] == false) {
-    if (c[2] == true) {
-      if (c[3] == true) {
-        h q[0];
-      }
-    }
-  }
-}
-```
-- Add formatting check for Unix style line endings i.e. `\n`. For any other line endings, errors are raised. ([#130](https://github.com/qBraid/pyqasm/pull/130))
-- Add `rebase` method to the `QasmModule`. Users now have the ability to rebase the quantum programs to any of the available `pyqasm.elements.BasisSet` ([#123](https://github.com/qBraid/pyqasm/pull/123)). Usage example - 
-
-```python
-In [9] : import pyqasm
-
-In [10]: qasm_input = """ OPENQASM 3.0;
-    ...: include "stdgates.inc";
-    ...: qubit[2] q;
-    ...: bit[2] c;
-    ...: 
-    ...: h q;
-    ...: x q;
-    ...: cz q[0], q[1];
-    ...: 
-    ...: c = measure q; 
-    ...: """
-
-In [11]: module = pyqasm.loads(qasm_input)
-
-In [12]: from pyqasm.elements import BasisSet
-
-In [13]: module.rebase(target_basis_set=BasisSet.ROTATIONAL_CX)
-Out[13]: <pyqasm.modules.qasm3.Qasm3Module at 0x103744e10>
-
-In [14]: print(pyqasm.dumps(module))
-OPENQASM 3.0;
-include "stdgates.inc";
-qubit[2] q;
-bit[2] c;
-ry(1.5707963267948966) q[0];
-rx(3.141592653589793) q[0];
-ry(1.5707963267948966) q[1];
-rx(3.141592653589793) q[1];
-rx(3.141592653589793) q[0];
-rx(3.141592653589793) q[1];
-ry(1.5707963267948966) q[1];
-rx(3.141592653589793) q[1];
-cx q[0], q[1];
-ry(1.5707963267948966) q[1];
-rx(3.141592653589793) q[1];
-c[0] = measure q[0];
-c[1] = measure q[1];
-```
-
-Current support for `BasisSet.CLIFFORD_T` decompositions is limited to non-parameterized gates only. 
-- Added `.gitattributes` file to specify unix-style line endings(`\n`) for all files ([#123](https://github.com/qBraid/pyqasm/pull/123))
-- Added support for `ctrl` modifiers. QASM3 programs with `ctrl @` modifiers can now be loaded as `QasmModule` objects ([#121](https://github.com/qBraid/pyqasm/pull/121)). Usage example - 
-
-```python
-In [18]: import pyqasm
-
-In [19]: qasm3_string = """
-    ...:     OPENQASM 3.0;
-    ...:     include "stdgates.inc";
-    ...:     qubit[3] q;
-    ...:     gate custom a, b, c {
-    ...:         ctrl @ x a, b;
-    ...:         ctrl(2) @ x a, b, c;
-    ...:     }
-    ...:     custom q[0], q[1], q[2];
-    ...:     """
-
-In [20]: module = pyqasm.loads(qasm3_string)
-
-In [21]: module.unroll()
-
-In [22]: print(pyqasm.dumps(module))
-OPENQASM 3.0;
-include "stdgates.inc";
-qubit[3] q;
-cx q[0], q[1];
-ccx q[0], q[1], q[2];
-```
 
 ### Improved / Modified
- - Refactored the initialization of `QasmModule` to remove default include statements. Only user supplied include statements are now added to the generated QASM code ([#86](https://github.com/qBraid/pyqasm/pull/86))
-- Update the `pre-release.yml` workflow to multi-platform builds. Added the pre-release version bump to the `pre_build.sh` script. ([#99](https://github.com/qBraid/pyqasm/pull/99))
-- Bumped qBraid-CLI dep in `tox.ini` to fix `qbraid headers` command formatting bug ([#129](https://github.com/qBraid/pyqasm/pull/129))
+- Re-wrote the `QasmAnalyzer.extract_qasm_version` method so that it extracts the program version just by looking at the [first non-comment line](https://github.com/openqasm/openqasm/blob/bb923eb9a84fdffe1ba6fc3c20d0b47a131523d9/source/language/comments.rst#version-string), instead of parsing the entire program ([#140](https://github.com/qBraid/pyqasm/pull/140)).
 
 ### Deprecated
 
 ### Removed
-- Unix-style line endings check in GitHub actions was removed in lieu of the `.gitattributes` file ([#123](https://github.com/qBraid/pyqasm/pull/123))
 
 ### Fixed
-- Fixed bugs in implementations of `gpi2` and `prx` gates ([#86](https://github.com/qBraid/pyqasm/pull/86))
 
 ### Dependencies
-- Update sphinx-autodoc-typehints requirement from <2.6,>=1.24 to >=1.24,<3.1 ([#119](https://github.com/qBraid/pyqasm/pull/119))
 
 ## Past Release Notes
 
 Archive of changelog entries from previous releases:
 
+- [v0.2.0](https://github.com/qBraid/pyqasm/releases/tag/v0.2.0)
 - [v0.1.0](https://github.com/qBraid/pyqasm/releases/tag/v0.1.0)
 - [v0.1.0-alpha](https://github.com/qBraid/pyqasm/releases/tag/v0.1.0-alpha)
 - [v0.0.3](https://github.com/qBraid/pyqasm/releases/tag/v0.0.3)
diff --git a/src/pyqasm/analyzer.py b/src/pyqasm/analyzer.py
index 9c8b71b..91d8a12 100644
--- a/src/pyqasm/analyzer.py
+++ b/src/pyqasm/analyzer.py
@@ -14,10 +14,10 @@
 """
 from __future__ import annotations
 
+import re
 from typing import TYPE_CHECKING, Any, Optional, Union
 
 import numpy as np
-from openqasm3 import parse
 from openqasm3.ast import (
     DiscreteSet,
     Expression,
@@ -28,7 +28,6 @@
     QuantumMeasurementStatement,
     RangeDefinition,
 )
-from openqasm3.parser import QASM3ParsingError
 
 from pyqasm.exceptions import QasmParsingError, ValidationError, raise_qasm3_error
 
@@ -209,27 +208,29 @@ def get_op_bit_list(operation):
             )
         return bit_list
 
-    @staticmethod
-    def extract_qasm_version(qasm: str) -> int:  # type: ignore # pylint: disable=R1710
+    @staticmethod  # pylint: disable-next=inconsistent-return-statements
+    def extract_qasm_version(qasm: str) -> float:  # type: ignore[return]
         """
-        Parses an OpenQASM program string to determine its major version, either 2 or 3.
+        Extracts the OpenQASM version from a given OpenQASM string.
 
         Args:
-            qasm (str): The OpenQASM program string.
+            qasm (str): The OpenQASM program as a string.
 
         Returns:
-            int: The OpenQASM version as an integer.
-
-        Raises:
-            QasmError: If the string does not represent a valid OpenQASM program.
+            The semantic version as a float.
         """
-        try:
-            # TODO: optimize this to just check the start of the program for version
-            parsed_program = parse(qasm)
-            assert parsed_program.version is not None
-            version = int(float(parsed_program.version))
-            return version
-        except (QASM3ParsingError, ValueError, TypeError):
-            raise_qasm3_error(
-                "Could not determine the OpenQASM version.", err_type=QasmParsingError
-            )
+        qasm = re.sub(r"//.*", "", qasm)
+        qasm = re.sub(r"/\*.*?\*/", "", qasm, flags=re.DOTALL)
+
+        lines = qasm.strip().splitlines()
+
+        for line in lines:
+            line = line.strip()
+            if line.startswith("OPENQASM"):
+                match = re.match(r"OPENQASM\s+(\d+)(?:\.(\d+))?;", line)
+                if match:
+                    major = int(match.group(1))
+                    minor = int(match.group(2)) if match.group(2) else 0
+                    return float(f"{major}.{minor}")
+
+        raise_qasm3_error("Could not determine the OpenQASM version.", err_type=QasmParsingError)


Test patch:
diff --git a/tests/qasm3/test_version.py b/tests/qasm3/test_version.py
index de2cf8c..98e330c 100644
--- a/tests/qasm3/test_version.py
+++ b/tests/qasm3/test_version.py
@@ -19,21 +19,68 @@
 from pyqasm.exceptions import QasmParsingError
 
 
-def test_extract_version():
-    """Test converting OpenQASM 3 program with openqasm3.ast.SwitchStatement."""
+@pytest.mark.parametrize(
+    "qasm_input, expected_version",
+    [
+        (
+            """
+        OPENQASM 3.0;
+        qubit q;
+        """,
+            3.0,
+        ),
+        (
+            """
+        OPENQASM 2;
+        qubit q;
+        """,
+            2.0,
+        ),
+        (
+            """
+        // Single-line comment
+        OPENQASM 1.5; // Inline comment
+        qubit q;
+        """,
+            1.5,
+        ),
+        (
+            """
+        /*
+          Block comment before the version string
+          describing the program.
+        */
+        OPENQASM 3.2;
+        qubit q;
+        """,
+            3.2,
+        ),
+    ],
+)
+def test_extract_qasm_version_valid(qasm_input, expected_version):
+    """Test valid OpenQASM version extraction with various inputs."""
+    assert Qasm3Analyzer.extract_qasm_version(qasm_input) == expected_version
 
-    qasm3_program = """
-    OPENQASM 3.0;
-    qubit q;
-    """
-    assert Qasm3Analyzer.extract_qasm_version(qasm3_program) == 3
 
-
-def test_invalid_raises_raises_err():
-    """Test converting OpenQASM 3 program with openqasm3.ast.SwitchStatement."""
-
-    qasm3_program = """
-    random string
-    """
+@pytest.mark.parametrize(
+    "qasm_input",
+    [
+        """
+        random string
+        """,
+        """
+        OPENQASM three.point.zero;
+        qubit q;
+        """,
+        """
+        OPENQASM 2.0
+        qubit q;
+        """,
+        "",
+        "   \n   \t   ",
+    ],
+)
+def test_extract_qasm_version_invalid(qasm_input):
+    """Test invalid OpenQASM inputs that should raise QasmParsingError."""
     with pytest.raises(QasmParsingError):
-        Qasm3Analyzer.extract_qasm_version(qasm3_program)
+        Qasm3Analyzer.extract_qasm_version(qasm_input)