diff --git a/CHANGELOG.md b/CHANGELOG.md index 976034fc..7b48491a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Types of changes: - A github workflow for validating `CHANGELOG` updates in a PR ([#214](https://github.com/qBraid/pyqasm/pull/214)) - 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)) - 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)) +- 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)) ### Improved / Modified - 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: ### Dependencies - 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)) +- Added `tabulate` to the testing dependencies to support new comparison table tests. ([#216](https://github.com/qBraid/pyqasm/pull/216)) ### Other diff --git a/docs/_static/logo.png b/docs/_static/logo.png index eaa9312e..f41c51fc 100644 Binary files a/docs/_static/logo.png and b/docs/_static/logo.png differ diff --git a/docs/conf.py b/docs/conf.py index e70eb0f0..6d71ede8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -41,7 +41,7 @@ # set_type_checking_flag = True autodoc_member_order = "bysource" autoclass_content = "both" -autodoc_mock_imports = ["openqasm3", "matplotlib"] +autodoc_mock_imports = ["openqasm3", "matplotlib", "tabulate"] napoleon_numpy_docstring = False todo_include_todos = True mathjax_path = "https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS-MML_HTMLorMML" diff --git a/pyproject.toml b/pyproject.toml index 8578e539..1e09c7d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,10 +40,10 @@ dependencies = ["numpy", "openqasm3[parser]>=1.0.0,<2.0.0"] [project.optional-dependencies] cli = ["typer>=0.12.1", "rich>=10.11.0", "typing-extensions"] -test = ["pytest", "pytest-cov", "pytest-mpl", "pillow<11.4.0", "matplotlib"] +test = ["pytest", "pytest-cov", "pytest-mpl", "pillow<11.4.0", "matplotlib", "tabulate"] lint = ["black", "isort>=6.0.0", "pylint", "mypy", "qbraid-cli>=0.10.2"] 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"] -visualization = ["pillow<11.4.0", "matplotlib"] +visualization = ["pillow<11.4.0", "matplotlib", "tabulate"] pulse = ["openpulse[parser]>=1.0.1"] [tool.setuptools.package-data] diff --git a/src/pyqasm/cli/unroll.py b/src/pyqasm/cli/unroll.py index d50e4ab3..4d7f74fd 100644 --- a/src/pyqasm/cli/unroll.py +++ b/src/pyqasm/cli/unroll.py @@ -29,7 +29,8 @@ from pyqasm import dumps, load from pyqasm.exceptions import QasmParsingError, UnrollError, ValidationError -from pyqasm.modules.base import QasmModule + +from .utils import skip_qasm_files_with_tag logger = logging.getLogger(__name__) logger.propagate = False @@ -57,7 +58,7 @@ def unroll_qasm_file(file_path: str) -> None: if file_path in skip_files: return - if QasmModule.skip_qasm_files_with_tag(content, "unroll"): + if skip_qasm_files_with_tag(content, "unroll"): skip_files.append(file_path) return diff --git a/src/pyqasm/cli/utils.py b/src/pyqasm/cli/utils.py new file mode 100644 index 00000000..f59afd4f --- /dev/null +++ b/src/pyqasm/cli/utils.py @@ -0,0 +1,38 @@ +# Copyright 2025 qBraid +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Module containing utility functions for the CLI scripts + +""" + + +def skip_qasm_files_with_tag(content: str, mode: str) -> bool: + """Check if a file should be skipped for a given mode (e.g., 'unroll', 'validate'). + + Args: + content (str): The file content. + mode (str): The operation mode ('unroll', 'validate', etc.) + + Returns: + bool: True if the file should be skipped, False otherwise. + """ + skip_tag = f"// pyqasm disable: {mode}" + generic_skip_tag = "// pyqasm: ignore" + for line in content.splitlines(): + if skip_tag in line or generic_skip_tag in line: + return True + if "OPENQASM" in line: + break + return False diff --git a/src/pyqasm/cli/validate.py b/src/pyqasm/cli/validate.py index c8e7a2f2..ea8c23f7 100644 --- a/src/pyqasm/cli/validate.py +++ b/src/pyqasm/cli/validate.py @@ -26,7 +26,8 @@ from pyqasm import load from pyqasm.exceptions import QasmParsingError, UnrollError, ValidationError -from pyqasm.modules.base import QasmModule + +from .utils import skip_qasm_files_with_tag logger = logging.getLogger(__name__) logger.propagate = False @@ -62,7 +63,7 @@ def validate_qasm_file(file_path: str) -> None: if file_path in skip_files: return - if QasmModule.skip_qasm_files_with_tag(content, "validate"): + if skip_qasm_files_with_tag(content, "validate"): skip_files.append(file_path) return diff --git a/src/pyqasm/modules/base.py b/src/pyqasm/modules/base.py index ee353b49..e82f5037 100644 --- a/src/pyqasm/modules/base.py +++ b/src/pyqasm/modules/base.py @@ -16,7 +16,11 @@ Definition of the base Qasm module """ +from __future__ import annotations + +import functools from abc import ABC, abstractmethod +from collections import Counter from copy import deepcopy from typing import Optional @@ -32,7 +36,33 @@ from pyqasm.visitor import QasmVisitor, ScopeManager -class QasmModule(ABC): # pylint: disable=too-many-instance-attributes +def track_user_operation(func): + """Decorator to track user operations on a QasmModule.""" + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + """Wrapper that logs the operation and its arguments.""" + func_name = func.__name__ + log_message = func_name + + if func_name == "depth": + decompose_native_gates = kwargs.get("decompose_native_gates", True) + if args: + decompose_native_gates = args[0] + log_message = f"depth(decompose_native_gates={decompose_native_gates})" + elif func_name == "unroll": + log_message = f"unroll({kwargs or {}})" + elif func_name == "rebase": + target_basis_set = args[0] + log_message = f"rebase({target_basis_set})" + + self._user_operations.append(log_message) + return func(self, *args, **kwargs) + + return wrapper + + +class QasmModule(ABC): # pylint: disable=too-many-instance-attributes, too-many-public-methods """Abstract class for a Qasm module Args: @@ -59,6 +89,7 @@ def __init__(self, name: str, program: Program): self._decompose_native_gates: Optional[bool] = None self._device_qubits: Optional[int] = None self._consolidate_qubits: Optional[bool] = False + self._user_operations: list[str] = ["load"] self._device_cycle_time: Optional[int] = None @property @@ -66,6 +97,11 @@ def name(self) -> str: """Returns the name of the module.""" return self._name + @property + def history(self) -> list[str]: + """Returns the user operations performed on the module.""" + return self._user_operations + @property def num_qubits(self) -> int: """Returns the number of qubits in the circuit.""" @@ -148,6 +184,7 @@ def has_measurements(self) -> bool: break return self._has_measurements + @track_user_operation def remove_measurements(self, in_place: bool = True) -> Optional["QasmModule"]: """Remove the measurement operations @@ -207,6 +244,7 @@ def has_barriers(self) -> bool: break return self._has_barriers + @track_user_operation def remove_barriers(self, in_place: bool = True) -> Optional["QasmModule"]: """Remove the barrier operations @@ -237,6 +275,7 @@ def remove_barriers(self, in_place: bool = True) -> Optional["QasmModule"]: return curr_module + @track_user_operation def remove_includes(self, in_place=True) -> Optional["QasmModule"]: """Remove the include statements from the module @@ -263,6 +302,7 @@ def remove_includes(self, in_place=True) -> Optional["QasmModule"]: return curr_module + @track_user_operation def depth(self, decompose_native_gates=True): """Calculate the depth of the unrolled openqasm program. @@ -454,6 +494,7 @@ def remove_idle_qubits(self, in_place: bool = True): return qasm_module + @track_user_operation def reverse_qubit_order(self, in_place=True): """Reverse the order of qubits in the module. @@ -514,6 +555,7 @@ def reverse_qubit_order(self, in_place=True): # 4. return the module return qasm_module + @track_user_operation def validate(self): """Validate the module""" if self._validated_program is True: @@ -534,6 +576,7 @@ def validate(self): raise err self._validated_program = True + @track_user_operation def unroll(self, **kwargs): """Unroll the module into basic qasm operations. @@ -575,6 +618,7 @@ def unroll(self, **kwargs): self._unrolled_ast = Program(statements=[], version=self.original_program.version) raise err + @track_user_operation def rebase(self, target_basis_set, in_place=True): """Rebase the AST to use a specified target basis set. @@ -624,25 +668,67 @@ def rebase(self, target_basis_set, in_place=True): return qasm_module - @staticmethod - def skip_qasm_files_with_tag(content: str, mode: str) -> bool: - """Check if a file should be skipped for a given mode (e.g., 'unroll', 'validate'). - - Args: - content (str): The file content. - mode (str): The operation mode ('unroll', 'validate', etc.) + def _get_gate_counts(self) -> dict[str, int]: + """Return a dictionary of gate counts in the unrolled program. Returns: - bool: True if the file should be skipped, False otherwise. + dict[str, int]: A dictionary of gate counts. """ - skip_tag = f"// pyqasm disable: {mode}" - generic_skip_tag = "// pyqasm: ignore" - for line in content.splitlines(): - if skip_tag in line or generic_skip_tag in line: - return True - if "OPENQASM" in line: - break - return False + if not self._unrolled_ast.statements: + self.unroll() + + gate_nodes = [ + s for s in self._unrolled_ast.statements if isinstance(s, qasm3_ast.QuantumGate) + ] + return dict(Counter(gate.name.name for gate in gate_nodes)) + + def compare(self, other_module: QasmModule): + """Compare two QasmModule objects across multiple attributes. + + Args: + other_module (QasmModule): The module to compare with. + """ + try: # pylint: disable-next=import-outside-toplevel + from tabulate import tabulate + except ImportError as exc: + raise ImportError("tabulate is required for the compare method. ") from exc + + if not isinstance(other_module, QasmModule): + raise TypeError(f"Expected QasmModule instance, got {type(other_module).__name__}") + + self_counts = self._get_gate_counts() + other_counts = other_module._get_gate_counts() + all_gates = sorted(list(set(self_counts) | set(other_counts))) + + # Format lists into multi-line strings for better readability + self_history_str = "\n".join(map(str, self.history)) + other_history_str = "\n".join(map(str, other_module.history)) + self_ext_gates_str = "\n".join(self._external_gates) + other_ext_gates_str = "\n".join(other_module._external_gates) + + table_data = [ + ["Qubits", self.num_qubits, other_module.num_qubits], + ["Classical Bits", self.num_clbits, other_module.num_clbits], + ["Measurements", self.has_measurements(), other_module.has_measurements()], + ["Barriers", self.has_barriers(), other_module.has_barriers()], + ["Depth", self.depth(), other_module.depth()], + ["External Gates", self_ext_gates_str, other_ext_gates_str], + ["History", self_history_str, other_history_str], + ["-" * 15, "-" * 15, "-" * 15], # Separator + ["Gate Counts", "Self", "Other"], + ["-" * 15, "-" * 15, "-" * 15], # Separator + ] + + for gate in all_gates: + table_data.append([gate, self_counts.get(gate, 0), other_counts.get(gate, 0)]) + + print( + tabulate( + table_data, + headers=["Attribute", "Self", "Other"], + tablefmt="grid", + ) + ) def __str__(self) -> str: """Return the string representation of the QASM program diff --git a/tests/visualization/test_compare.py b/tests/visualization/test_compare.py new file mode 100644 index 00000000..98fa849b --- /dev/null +++ b/tests/visualization/test_compare.py @@ -0,0 +1,122 @@ +# Copyright 2025 qBraid +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Test the compare() method of the QasmModule class. +""" + +import pytest + +from pyqasm.elements import BasisSet +from pyqasm.entrypoint import loads + + +def test_compare_method_output(capsys): + """ + Tests the output of the QasmModule.compare() method to ensure it + correctly identifies and displays differences between two modules. + """ + qasm_str = """ + OPENQASM 3.0; + include "stdgates.inc"; + + qubit[2] q; + h q[0]; + cx q[0], q[1]; + barrier q; + """ + + module_a = loads(qasm_str) + module_b = loads(qasm_str) + + module_a.unroll() + module_b.unroll(unroll_barriers=False, external_gates=["cx"]) + module_b.rebase(BasisSet.ROTATIONAL_CX) + + module_a.compare(module_b) + captured = capsys.readouterr() + stdout = captured.out + + print(stdout) + + assert "Attribute" in stdout + assert "Self" in stdout + assert "Other" in stdout + assert "Qubits" in stdout + assert "Depth" in stdout + assert "History" in stdout + assert "External Gates" in stdout + assert "Gate Counts" in stdout + assert "unroll({})" in stdout + assert "unroll({'unroll_barriers': False, 'external_gates': ['cx']})" in stdout + assert "rebase(BasisSet.ROTATIONAL_CX)" in stdout + assert "h" in stdout + assert "cx" in stdout + assert "barrier" in stdout + + +def test_compare_with_measurements_and_barriers(capsys): + """Test compare method with modules that have measurements and barriers.""" + qasm_with_both = """ + OPENQASM 3.0; + include "stdgates.inc"; + + qubit[2] q; + bit[2] c; + h q[0]; + barrier q; + cx q[0], q[1]; + c = measure q; + """ + + qasm_without_both = """ + OPENQASM 3.0; + include "stdgates.inc"; + + qubit[2] q; + h q[0]; + cx q[0], q[1]; + """ + + module_with_both = loads(qasm_with_both) + module_without_both = loads(qasm_without_both) + + module_with_both.compare(module_without_both) + captured = capsys.readouterr() + stdout = captured.out + + assert "Measurements" in stdout + assert "Barriers" in stdout + assert "True" in stdout + assert "False" in stdout + assert "Classical Bits" in stdout + + +def test_compare_invalid_type(): + """Test compare method with invalid input type raises TypeError.""" + qasm_str = """ + OPENQASM 3.0; + include "stdgates.inc"; + + qubit[2] q; + h q[0]; + """ + + module = loads(qasm_str) + + with pytest.raises(TypeError, match="Expected QasmModule instance, got str"): + module.compare("not a module") + + with pytest.raises(TypeError, match="Expected QasmModule instance, got int"): + module.compare(42) diff --git a/tox.ini b/tox.ini index 9c488a8a..6f6f4b90 100644 --- a/tox.ini +++ b/tox.ini @@ -51,7 +51,9 @@ commands = [testenv:mypy] envdir = .tox/linters skip_install = true -deps = mypy +deps = + mypy + types-tabulate commands = mypy src examples