Skip to content

Commit c81a5dc

Browse files
Add compare method for QasmModules (#233)
* issue 216 added * fix 216 changes * added decorator, changelogs, and test * lazy import * fix format ci * format * update changelog --------- Co-authored-by: Harshit Gupta <harshit.11235@gmail.com>
1 parent b1e9d18 commit c81a5dc

10 files changed

Lines changed: 277 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Types of changes:
1919
- A github workflow for validating `CHANGELOG` updates in a PR ([#214](https://github.com/qBraid/pyqasm/pull/214))
2020
- Added `unroll` command support in PYQASM CLI with options skipping files, overwriting originals files, and specifying output paths.([#224](https://github.com/qBraid/pyqasm/pull/224))
2121
- Added `.github/copilot-instructions.md` to the repository to document coding standards and design principles for pyqasm. This file provides detailed guidance on documentation, static typing, formatting, error handling, and adherence to the QASM specification for all code contributions. ([#234](https://github.com/qBraid/pyqasm/pull/234))
22+
- Added a new `QasmModule.compare` method to compare two QASM modules, providing a detailed report of differences in gates, qubits, and measurements. This method is useful for comparing two identifying differences in QASM programs, their structure and operations. ([#233](https://github.com/qBraid/pyqasm/pull/233))
2223

2324
### Improved / Modified
2425
- Added `slots=True` parameter to the data classes in `elements.py` to improve memory efficiency ([#218](https://github.com/qBraid/pyqasm/pull/218))
@@ -55,6 +56,7 @@ Types of changes:
5556

5657
### Dependencies
5758
- Add `pillow<11.3.0` dependency for test and visualization to avoid CI errors in Linux builds ([#226](https://github.com/qBraid/pyqasm/pull/226))
59+
- Added `tabulate` to the testing dependencies to support new comparison table tests. ([#216](https://github.com/qBraid/pyqasm/pull/216))
5860

5961
### Other
6062

docs/_static/logo.png

-2 Bytes
Loading

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
# set_type_checking_flag = True
4242
autodoc_member_order = "bysource"
4343
autoclass_content = "both"
44-
autodoc_mock_imports = ["openqasm3", "matplotlib"]
44+
autodoc_mock_imports = ["openqasm3", "matplotlib", "tabulate"]
4545
napoleon_numpy_docstring = False
4646
todo_include_todos = True
4747
mathjax_path = "https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS-MML_HTMLorMML"

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@ dependencies = ["numpy", "openqasm3[parser]>=1.0.0,<2.0.0"]
4040

4141
[project.optional-dependencies]
4242
cli = ["typer>=0.12.1", "rich>=10.11.0", "typing-extensions"]
43-
test = ["pytest", "pytest-cov", "pytest-mpl", "pillow<11.4.0", "matplotlib"]
43+
test = ["pytest", "pytest-cov", "pytest-mpl", "pillow<11.4.0", "matplotlib", "tabulate"]
4444
lint = ["black", "isort>=6.0.0", "pylint", "mypy", "qbraid-cli>=0.10.2"]
4545
docs = ["sphinx>=7.3.7,<8.3.0", "sphinx-autodoc-typehints>=1.24,<3.2", "sphinx-rtd-theme>=2.0.0,<4.0.0", "docutils<0.22", "sphinx-copybutton"]
46-
visualization = ["pillow<11.4.0", "matplotlib"]
46+
visualization = ["pillow<11.4.0", "matplotlib", "tabulate"]
4747
pulse = ["openpulse[parser]>=1.0.1"]
4848

4949
[tool.setuptools.package-data]

src/pyqasm/cli/unroll.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929

3030
from pyqasm import dumps, load
3131
from pyqasm.exceptions import QasmParsingError, UnrollError, ValidationError
32-
from pyqasm.modules.base import QasmModule
32+
33+
from .utils import skip_qasm_files_with_tag
3334

3435
logger = logging.getLogger(__name__)
3536
logger.propagate = False
@@ -57,7 +58,7 @@ def unroll_qasm_file(file_path: str) -> None:
5758

5859
if file_path in skip_files:
5960
return
60-
if QasmModule.skip_qasm_files_with_tag(content, "unroll"):
61+
if skip_qasm_files_with_tag(content, "unroll"):
6162
skip_files.append(file_path)
6263
return
6364

src/pyqasm/cli/utils.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copyright 2025 qBraid
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Module containing utility functions for the CLI scripts
17+
18+
"""
19+
20+
21+
def skip_qasm_files_with_tag(content: str, mode: str) -> bool:
22+
"""Check if a file should be skipped for a given mode (e.g., 'unroll', 'validate').
23+
24+
Args:
25+
content (str): The file content.
26+
mode (str): The operation mode ('unroll', 'validate', etc.)
27+
28+
Returns:
29+
bool: True if the file should be skipped, False otherwise.
30+
"""
31+
skip_tag = f"// pyqasm disable: {mode}"
32+
generic_skip_tag = "// pyqasm: ignore"
33+
for line in content.splitlines():
34+
if skip_tag in line or generic_skip_tag in line:
35+
return True
36+
if "OPENQASM" in line:
37+
break
38+
return False

src/pyqasm/cli/validate.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626

2727
from pyqasm import load
2828
from pyqasm.exceptions import QasmParsingError, UnrollError, ValidationError
29-
from pyqasm.modules.base import QasmModule
29+
30+
from .utils import skip_qasm_files_with_tag
3031

3132
logger = logging.getLogger(__name__)
3233
logger.propagate = False
@@ -62,7 +63,7 @@ def validate_qasm_file(file_path: str) -> None:
6263

6364
if file_path in skip_files:
6465
return
65-
if QasmModule.skip_qasm_files_with_tag(content, "validate"):
66+
if skip_qasm_files_with_tag(content, "validate"):
6667
skip_files.append(file_path)
6768
return
6869

src/pyqasm/modules/base.py

Lines changed: 103 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
Definition of the base Qasm module
1717
"""
1818

19+
from __future__ import annotations
20+
21+
import functools
1922
from abc import ABC, abstractmethod
23+
from collections import Counter
2024
from copy import deepcopy
2125
from typing import Optional
2226

@@ -32,7 +36,33 @@
3236
from pyqasm.visitor import QasmVisitor, ScopeManager
3337

3438

35-
class QasmModule(ABC): # pylint: disable=too-many-instance-attributes
39+
def track_user_operation(func):
40+
"""Decorator to track user operations on a QasmModule."""
41+
42+
@functools.wraps(func)
43+
def wrapper(self, *args, **kwargs):
44+
"""Wrapper that logs the operation and its arguments."""
45+
func_name = func.__name__
46+
log_message = func_name
47+
48+
if func_name == "depth":
49+
decompose_native_gates = kwargs.get("decompose_native_gates", True)
50+
if args:
51+
decompose_native_gates = args[0]
52+
log_message = f"depth(decompose_native_gates={decompose_native_gates})"
53+
elif func_name == "unroll":
54+
log_message = f"unroll({kwargs or {}})"
55+
elif func_name == "rebase":
56+
target_basis_set = args[0]
57+
log_message = f"rebase({target_basis_set})"
58+
59+
self._user_operations.append(log_message)
60+
return func(self, *args, **kwargs)
61+
62+
return wrapper
63+
64+
65+
class QasmModule(ABC): # pylint: disable=too-many-instance-attributes, too-many-public-methods
3666
"""Abstract class for a Qasm module
3767
3868
Args:
@@ -59,13 +89,19 @@ def __init__(self, name: str, program: Program):
5989
self._decompose_native_gates: Optional[bool] = None
6090
self._device_qubits: Optional[int] = None
6191
self._consolidate_qubits: Optional[bool] = False
92+
self._user_operations: list[str] = ["load"]
6293
self._device_cycle_time: Optional[int] = None
6394

6495
@property
6596
def name(self) -> str:
6697
"""Returns the name of the module."""
6798
return self._name
6899

100+
@property
101+
def history(self) -> list[str]:
102+
"""Returns the user operations performed on the module."""
103+
return self._user_operations
104+
69105
@property
70106
def num_qubits(self) -> int:
71107
"""Returns the number of qubits in the circuit."""
@@ -148,6 +184,7 @@ def has_measurements(self) -> bool:
148184
break
149185
return self._has_measurements
150186

187+
@track_user_operation
151188
def remove_measurements(self, in_place: bool = True) -> Optional["QasmModule"]:
152189
"""Remove the measurement operations
153190
@@ -207,6 +244,7 @@ def has_barriers(self) -> bool:
207244
break
208245
return self._has_barriers
209246

247+
@track_user_operation
210248
def remove_barriers(self, in_place: bool = True) -> Optional["QasmModule"]:
211249
"""Remove the barrier operations
212250
@@ -237,6 +275,7 @@ def remove_barriers(self, in_place: bool = True) -> Optional["QasmModule"]:
237275

238276
return curr_module
239277

278+
@track_user_operation
240279
def remove_includes(self, in_place=True) -> Optional["QasmModule"]:
241280
"""Remove the include statements from the module
242281
@@ -263,6 +302,7 @@ def remove_includes(self, in_place=True) -> Optional["QasmModule"]:
263302

264303
return curr_module
265304

305+
@track_user_operation
266306
def depth(self, decompose_native_gates=True):
267307
"""Calculate the depth of the unrolled openqasm program.
268308
@@ -454,6 +494,7 @@ def remove_idle_qubits(self, in_place: bool = True):
454494

455495
return qasm_module
456496

497+
@track_user_operation
457498
def reverse_qubit_order(self, in_place=True):
458499
"""Reverse the order of qubits in the module.
459500
@@ -514,6 +555,7 @@ def reverse_qubit_order(self, in_place=True):
514555
# 4. return the module
515556
return qasm_module
516557

558+
@track_user_operation
517559
def validate(self):
518560
"""Validate the module"""
519561
if self._validated_program is True:
@@ -534,6 +576,7 @@ def validate(self):
534576
raise err
535577
self._validated_program = True
536578

579+
@track_user_operation
537580
def unroll(self, **kwargs):
538581
"""Unroll the module into basic qasm operations.
539582
@@ -575,6 +618,7 @@ def unroll(self, **kwargs):
575618
self._unrolled_ast = Program(statements=[], version=self.original_program.version)
576619
raise err
577620

621+
@track_user_operation
578622
def rebase(self, target_basis_set, in_place=True):
579623
"""Rebase the AST to use a specified target basis set.
580624
@@ -624,25 +668,67 @@ def rebase(self, target_basis_set, in_place=True):
624668

625669
return qasm_module
626670

627-
@staticmethod
628-
def skip_qasm_files_with_tag(content: str, mode: str) -> bool:
629-
"""Check if a file should be skipped for a given mode (e.g., 'unroll', 'validate').
630-
631-
Args:
632-
content (str): The file content.
633-
mode (str): The operation mode ('unroll', 'validate', etc.)
671+
def _get_gate_counts(self) -> dict[str, int]:
672+
"""Return a dictionary of gate counts in the unrolled program.
634673
635674
Returns:
636-
bool: True if the file should be skipped, False otherwise.
675+
dict[str, int]: A dictionary of gate counts.
637676
"""
638-
skip_tag = f"// pyqasm disable: {mode}"
639-
generic_skip_tag = "// pyqasm: ignore"
640-
for line in content.splitlines():
641-
if skip_tag in line or generic_skip_tag in line:
642-
return True
643-
if "OPENQASM" in line:
644-
break
645-
return False
677+
if not self._unrolled_ast.statements:
678+
self.unroll()
679+
680+
gate_nodes = [
681+
s for s in self._unrolled_ast.statements if isinstance(s, qasm3_ast.QuantumGate)
682+
]
683+
return dict(Counter(gate.name.name for gate in gate_nodes))
684+
685+
def compare(self, other_module: QasmModule):
686+
"""Compare two QasmModule objects across multiple attributes.
687+
688+
Args:
689+
other_module (QasmModule): The module to compare with.
690+
"""
691+
try: # pylint: disable-next=import-outside-toplevel
692+
from tabulate import tabulate
693+
except ImportError as exc:
694+
raise ImportError("tabulate is required for the compare method. ") from exc
695+
696+
if not isinstance(other_module, QasmModule):
697+
raise TypeError(f"Expected QasmModule instance, got {type(other_module).__name__}")
698+
699+
self_counts = self._get_gate_counts()
700+
other_counts = other_module._get_gate_counts()
701+
all_gates = sorted(list(set(self_counts) | set(other_counts)))
702+
703+
# Format lists into multi-line strings for better readability
704+
self_history_str = "\n".join(map(str, self.history))
705+
other_history_str = "\n".join(map(str, other_module.history))
706+
self_ext_gates_str = "\n".join(self._external_gates)
707+
other_ext_gates_str = "\n".join(other_module._external_gates)
708+
709+
table_data = [
710+
["Qubits", self.num_qubits, other_module.num_qubits],
711+
["Classical Bits", self.num_clbits, other_module.num_clbits],
712+
["Measurements", self.has_measurements(), other_module.has_measurements()],
713+
["Barriers", self.has_barriers(), other_module.has_barriers()],
714+
["Depth", self.depth(), other_module.depth()],
715+
["External Gates", self_ext_gates_str, other_ext_gates_str],
716+
["History", self_history_str, other_history_str],
717+
["-" * 15, "-" * 15, "-" * 15], # Separator
718+
["Gate Counts", "Self", "Other"],
719+
["-" * 15, "-" * 15, "-" * 15], # Separator
720+
]
721+
722+
for gate in all_gates:
723+
table_data.append([gate, self_counts.get(gate, 0), other_counts.get(gate, 0)])
724+
725+
print(
726+
tabulate(
727+
table_data,
728+
headers=["Attribute", "Self", "Other"],
729+
tablefmt="grid",
730+
)
731+
)
646732

647733
def __str__(self) -> str:
648734
"""Return the string representation of the QASM program

0 commit comments

Comments
 (0)