From 9e380b33a47a480a7d0edebd15916a8c9938f261 Mon Sep 17 00:00:00 2001 From: vinayswamik Date: Sun, 29 Jun 2025 20:14:37 -0500 Subject: [PATCH 01/10] update CLI main.py, added unroll.py, update test_cli_commands.py - Added `unroll` command to PYQASM cli. - Added test cases. --- src/pyqasm/cli/main.py | 35 +++ src/pyqasm/cli/unroll.py | 153 ++++++++++ tests/cli/test_cli_commands.py | 503 +++++++++++++++++++++++++++++++++ 3 files changed, 691 insertions(+) create mode 100644 src/pyqasm/cli/unroll.py diff --git a/src/pyqasm/cli/main.py b/src/pyqasm/cli/main.py index b70d9de4..bc9732c8 100644 --- a/src/pyqasm/cli/main.py +++ b/src/pyqasm/cli/main.py @@ -18,11 +18,13 @@ """ import sys +from typing import Optional try: import typer from typing_extensions import Annotated + from pyqasm.cli.unroll import unroll_qasm from pyqasm.cli.validate import validate_paths_exist, validate_qasm except ImportError as err: print( @@ -65,6 +67,39 @@ def validate( # pylint: disable=dangerous-default-value validate_qasm(src_paths, skip_files) +@app.command(name="unroll", help="Unroll OpenQASM files.") +def unroll( # pylint: disable=dangerous-default-value + src_paths: Annotated[ + list[str], + typer.Argument( + ..., help="Source file or directory paths to unroll.", callback=validate_paths_exist + ), + ], + skip_files: Annotated[ + list[str], + typer.Option( + "--skip", "-s", help="Files to skip during unrolling.", callback=validate_paths_exist + ), + ] = [], + overwrite: Annotated[ + bool, + typer.Option("--overwrite", help="Overwrite original files instead of creating new ones."), + ] = False, + output: Annotated[ + Optional[str], + typer.Option( + "--output", "-o", help="Output file path (can only be used with a single input file)." + ), + ] = None, +): + """Unroll OpenQASM files.""" + # Validate that output_path is only used with a single file + if output and len(src_paths) > 1: + raise typer.BadParameter("--output can only be used with a single input file") + + unroll_qasm(src_paths, skip_files, overwrite, output) + + @app.callback(invoke_without_command=True) def main( ctx: typer.Context, diff --git a/src/pyqasm/cli/unroll.py b/src/pyqasm/cli/unroll.py new file mode 100644 index 00000000..4f2c6076 --- /dev/null +++ b/src/pyqasm/cli/unroll.py @@ -0,0 +1,153 @@ +# 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. + +""" +Script to unroll OpenQASM files + +""" + +import logging +import os +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console + +from pyqasm import dumps, load +from pyqasm.exceptions import QasmParsingError, UnrollError, ValidationError + +logger = logging.getLogger(__name__) +logger.propagate = False + + +# pylint: disable-next=too-many-locals,too-many-statements +def unroll_qasm( + src_paths: list[str], + skip_files: Optional[list[str]] = None, + overwrite: bool = False, + output_path: Optional[str] = None, +) -> None: + """Unroll OpenQASM files""" + skip_files = skip_files or [] + + failed_files: list[tuple[str, Exception]] = [] + successful_files: list[str] = [] + + console = Console() + + def should_skip(filepath: str, content: str) -> bool: + if filepath in skip_files: + return True + + skip_tag = "// pyqasm: ignore" + + for line in content.splitlines(): + if skip_tag in line: + return True + if "OPENQASM" in line: + break + + return False + + def unroll_qasm_file(file_path: str) -> None: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + if should_skip(file_path, content): + return + + try: + module = load(file_path) + module.unroll() + unrolled_content = dumps(module) + + # Determine output file path + if output_path and len(src_paths) == 1: + # Use explicitly specified output path + output_file = output_path + os.makedirs(os.path.dirname(output_file), exist_ok=True) + elif overwrite: + output_file = file_path + else: + # Create new file with _unrolled suffix + path = Path(file_path) + output_file = str(path.parent / f"{path.stem}_unrolled{path.suffix}") + + # Write the unrolled content + with open(output_file, "w", encoding="utf-8") as f: + f.write(unrolled_content) + + successful_files.append(file_path) + + except (ValidationError, UnrollError, QasmParsingError) as err: + failed_files.append((file_path, err)) + except Exception as uncaught_err: # pylint: disable=broad-exception-caught + logger.debug("Uncaught error in %s", file_path, exc_info=uncaught_err) + failed_files.append((file_path, uncaught_err)) + + def process_files_in_directory(directory: str) -> int: + count = 0 + if not os.path.isdir(directory): + return count + for root, _, files in os.walk(directory): + for file in files: + if file.endswith(".qasm"): + file_path = os.path.join(root, file) + unroll_qasm_file(file_path) + count += 1 + return count + + checked = 0 + for item in src_paths: + if os.path.isdir(item): + checked += process_files_in_directory(item) + elif os.path.isfile(item) and item.endswith(".qasm"): + unroll_qasm_file(item) + checked += 1 + + checked -= len(skip_files) + + if checked == 0: + console.print("No .qasm files present. Nothing to do.") + raise typer.Exit(0) + + # Report results + if successful_files: + s_success = "" if len(successful_files) == 1 else "s" + console.print( + f"[green]Successfully unrolled {len(successful_files)} file{s_success}[/green]" + ) + + if failed_files: + for file, err in failed_files: + category = ( + "".join(["-" + c.lower() if c.isupper() else c for c in type(err).__name__]) + .lstrip("-") + .removesuffix("-error") + ) + # pylint: disable-next=anomalous-backslash-in-string + console.print(f"Failed to unroll: {file} ({err}) [yellow]\\[{category}][/yellow]") + + num_failed = len(failed_files) + s1 = "" if num_failed == 1 else "s" + console.print( + f"[red]Failed to unroll {num_failed} file{s1} " + f"(checked {checked} source file{'s' if checked != 1 else ''})[/red]" + ) + raise typer.Exit(1) + + s_checked = "" if checked == 1 else "s" + console.print(f"[green]Success: unrolled {checked} source file{s_checked}[/green]") + raise typer.Exit(0) diff --git a/tests/cli/test_cli_commands.py b/tests/cli/test_cli_commands.py index f6e29b5e..a21b1b47 100644 --- a/tests/cli/test_cli_commands.py +++ b/tests/cli/test_cli_commands.py @@ -31,6 +31,7 @@ ) from pyqasm.cli.main import app +from pyqasm.cli.unroll import unroll_qasm from pyqasm.cli.validate import validate_qasm CLI_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -157,3 +158,505 @@ def test_main_help_flag(runner: CliRunner): assert result.exit_code == 0 assert "Usage:" in result.output assert "validate" in result.output + assert "unroll" in result.output + + +# Unroll command tests +def test_unroll_command_help(runner: CliRunner): + """Test the `unroll` CLI command help.""" + result = runner.invoke(app, ["unroll", "--help"]) + + assert result.exit_code == 0 + assert "Unroll OpenQASM files." in result.output + assert "--skip" in result.output + assert "--overwrite" in result.output + assert "--output" in result.output + + +def test_unroll_command_single_file(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with a single file.""" + # Create a test file + test_file = tmp_path / "test.qasm" + test_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate q { h q; } + qubit[2] q; + hgate q[0]; + hgate q[1]; + """ + ) + + result = runner.invoke(app, ["unroll", str(test_file)]) + + assert result.exit_code == 0 + assert "Successfully unrolled 1 file" in result.output + assert "Success: unrolled 1 source file" in result.output + + # Check that unrolled file was created + unrolled_file = tmp_path / "test_unrolled.qasm" + assert unrolled_file.exists() + + +def test_unroll_command_single_file_with_output(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with explicit output path.""" + # Create a test file + test_file = tmp_path / "test.qasm" + test_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate q { h q; } + qubit[2] q; + hgate q[0]; + hgate q[1]; + """ + ) + + output_file = tmp_path / "custom_output.qasm" + result = runner.invoke(app, ["unroll", str(test_file), "--output", str(output_file)]) + + assert result.exit_code == 0 + assert "Successfully unrolled 1 file" in result.output + assert output_file.exists() + + # Verify content was unrolled (custom gate should be expanded) + content = output_file.read_text() + assert "h q[0];" in content + assert "h q[1];" in content + assert "gate hgate" not in content # Custom gate should be removed + + +def test_unroll_command_single_file_overwrite(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with overwrite option.""" + # Create a test file + test_file = tmp_path / "test.qasm" + original_content = """ + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate q { h q; } + qubit[2] q; + hgate q[0]; + hgate q[1]; + """ + test_file.write_text(original_content) + + result = runner.invoke(app, ["unroll", str(test_file), "--overwrite"]) + + assert result.exit_code == 0 + assert "Successfully unrolled 1 file" in result.output + + # Check that original file was overwritten + new_content = test_file.read_text() + assert new_content != original_content + assert "h q[0];" in new_content + assert "h q[1];" in new_content + assert "gate hgate" not in new_content + + +def test_unroll_command_directory(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with a directory.""" + # Create test directory with multiple files + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + # Create multiple test files + for i in range(3): + test_file = test_dir / f"test{i}.qasm" + test_file.write_text( + f""" + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate{i} q {{ h q; }} + qubit[2] q; + hgate{i} q[0]; + hgate{i} q[1]; + """ + ) + + result = runner.invoke(app, ["unroll", str(test_dir)]) + + assert result.exit_code == 0 + assert "Successfully unrolled 3 files" in result.output + + # Check that unrolled files were created + for i in range(3): + unrolled_file = test_dir / f"test{i}_unrolled.qasm" + assert unrolled_file.exists() + + +def test_unroll_command_directory_overwrite(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with directory and overwrite.""" + # Create test directory with multiple files + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + # Create multiple test files + original_contents = [] + for i in range(2): + test_file = test_dir / f"test{i}.qasm" + content = f""" + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate{i} q {{ h q; }} + qubit[2] q; + hgate{i} q[0]; + hgate{i} q[1] + """ + test_file.write_text(content) + original_contents.append(content) + + result = runner.invoke(app, ["unroll", str(test_dir), "--overwrite"]) + + assert result.exit_code == 0 + assert "Successfully unrolled 2 files" in result.output + + # Check that original files were overwritten + for i in range(2): + test_file = test_dir / f"test{i}.qasm" + new_content = test_file.read_text() + assert new_content != original_contents[i] + assert "h q[0];" in new_content + assert "h q[1];" in new_content + + +def test_unroll_command_with_skip(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with skip option.""" + # Create test directory with multiple files + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + # Create test files + for i in range(3): + test_file = test_dir / f"test{i}.qasm" + test_file.write_text( + f""" + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate{i} q {{ h q; }} + qubit[2] q; + hgate{i} q[0]; + hgate{i} q[1]; + """ + ) + + # Skip one file + skip_file = str(test_dir / "test1.qasm") + result = runner.invoke(app, ["unroll", str(test_dir), "--skip", skip_file]) + + assert result.exit_code == 0 + assert "Successfully unrolled 2 files" in result.output + + # Check that only 2 files were processed + assert (test_dir / "test0_unrolled.qasm").exists() + assert not (test_dir / "test1_unrolled.qasm").exists() + assert (test_dir / "test2_unrolled.qasm").exists() + + +def test_unroll_command_with_skip_tag(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with skip tag in file.""" + # Create test directory + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + # Create a file with skip tag + skip_file = test_dir / "skip_me.qasm" + skip_file.write_text( + """ + // pyqasm: ignore + OPENQASM 3.0; + include "stdgates.inc"; + qubit[2] q; + h q[0]; + """ + ) + + # Create a normal file + normal_file = test_dir / "normal.qasm" + normal_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate q { h q; } + qubit[2] q; + hgate q[0]; + """ + ) + + result = runner.invoke(app, ["unroll", str(test_dir)]) + + assert result.exit_code == 0 + assert "Successfully unrolled 1 file" in result.output + + # Check that only normal file was processed + assert (test_dir / "normal_unrolled.qasm").exists() + assert not (test_dir / "skip_me_unrolled.qasm").exists() + + +def test_unroll_command_multiple_files_with_output_error(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with multiple files and output option (should fail).""" + # Create test files + test_file1 = tmp_path / "test1.qasm" + test_file1.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") + + test_file2 = tmp_path / "test2.qasm" + test_file2.write_text("OPENQASM 3.0; qubit[1] q; x q[0];") + + result = runner.invoke( + app, ["unroll", str(test_file1), str(test_file2), "--output", "output.qasm"] + ) + + assert result.exit_code == 2 + assert "--output can only be used with a single input file" in result.output + + +def test_unroll_command_with_invalid_file(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with an invalid file.""" + # Create an invalid test file + test_file = tmp_path / "invalid.qasm" + test_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + h q[2]; // Invalid: index 2 out of range + """ + ) + + result = runner.invoke(app, ["unroll", str(test_file)]) + + assert result.exit_code == 1 + assert "Failed to unroll:" in result.output + # The error message has extra spaces, so let's normalize it + normalized_output = " ".join(result.output.split()) + assert "Index 2 out of range for register of size 1 in qubit" in normalized_output + + +def test_unroll_command_mixed_success_and_failure(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with mixed success and failure.""" + # Create test directory + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + # Create a valid file + valid_file = test_dir / "valid.qasm" + valid_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + h q[0]; + """ + ) + + # Create an invalid file + invalid_file = test_dir / "invalid.qasm" + invalid_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + h q[2]; // Invalid: index 2 out of range + """ + ) + + result = runner.invoke(app, ["unroll", str(test_dir)]) + + assert result.exit_code == 1 + assert "Successfully unrolled 1 file" in result.output + assert "Failed to unroll:" in result.output + assert "Failed to unroll 1 file (checked 2 source files)" in result.output + + # Check that valid file was processed + assert (test_dir / "valid_unrolled.qasm").exists() + + +def test_unroll_command_no_files(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with no .qasm files.""" + # Create empty directory + empty_dir = tmp_path / "empty_dir" + empty_dir.mkdir() + + # Create a non-qasm file + text_file = empty_dir / "text.txt" + text_file.write_text("This is not a QASM file") + + result = runner.invoke(app, ["unroll", str(empty_dir)]) + + assert result.exit_code == 0 + assert "No .qasm files present. Nothing to do." in result.output + + +def test_unroll_command_nonexistent_file(runner: CliRunner): + """Test the `unroll` CLI command with nonexistent file.""" + result = runner.invoke(app, ["unroll", "nonexistent.qasm"]) + + assert result.exit_code == 2 + assert "does not exist" in result.output + + +def test_unroll_command_nonexistent_directory(runner: CliRunner): + """Test the `unroll` CLI command with nonexistent directory.""" + result = runner.invoke(app, ["unroll", "nonexistent_directory/"]) + assert result.exit_code == 2 + assert "does not" in result.output and "exist" in result.output + + +def test_unroll_command_output_to_nonexistent_directory(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with output to nonexistent directory.""" + # Create a test file + test_file = tmp_path / "test.qasm" + test_file.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") + + # Try to output to nonexistent directory + output_path = tmp_path / "nonexistent_dir" / "output.qasm" + result = runner.invoke(app, ["unroll", str(test_file), "--output", str(output_path)]) + + # This should succeed and create the directory + assert result.exit_code == 0 + assert output_path.exists() + + +def test_unroll_command_overwrite_and_output_precedence(runner: CliRunner, tmp_path): + """Test that --output takes precedence over --overwrite.""" + # Create a test file + test_file = tmp_path / "test.qasm" + original_content = """ + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate q { h q; } + qubit[2] q; + hgate q[0]; + """ + test_file.write_text(original_content) + + output_file = tmp_path / "output.qasm" + result = runner.invoke( + app, ["unroll", str(test_file), "--overwrite", "--output", str(output_file)] + ) + + assert result.exit_code == 0 + assert "Successfully unrolled 1 file" in result.output + + # Check that original file was not changed + assert test_file.read_text() == original_content + # Check that output file was created + assert output_file.exists() + + +def test_unroll_command_mixed_inputs(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with mixed file and directory inputs.""" + # Create a test file + test_file = tmp_path / "test.qasm" + test_file.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") + + # Create a test directory + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + dir_file = test_dir / "dir_test.qasm" + dir_file.write_text("OPENQASM 3.0; qubit[1] q; x q[0];") + + result = runner.invoke(app, ["unroll", str(test_file), str(test_dir)]) + + assert result.exit_code == 0 + assert "Successfully unrolled 2 files" in result.output + + # Check that both files were processed + assert (tmp_path / "test_unrolled.qasm").exists() + assert (test_dir / "dir_test_unrolled.qasm").exists() + + +def test_unroll_command_skip_multiple_files(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with multiple skip files.""" + # Create test directory + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + # Create multiple test files + for i in range(4): + test_file = test_dir / f"test{i}.qasm" + test_file.write_text(f"OPENQASM 3.0; qubit[1] q; h q[0];") + + # Skip multiple files using multiple --skip flags with full paths + result = runner.invoke( + app, + [ + "unroll", + str(test_dir), + "--skip", + str(test_dir / "test0.qasm"), + "--skip", + str(test_dir / "test2.qasm"), + ], + ) + + assert result.exit_code == 0 + assert "Successfully unrolled 2 files" in result.output + + # Check that only non-skipped files were processed + assert not (test_dir / "test0_unrolled.qasm").exists() + assert (test_dir / "test1_unrolled.qasm").exists() + assert not (test_dir / "test2_unrolled.qasm").exists() + assert (test_dir / "test3_unrolled.qasm").exists() + + +# Direct function tests for unroll_qasm +def test_unroll_qasm_function_single_file(capsys, tmp_path): + """Test unroll_qasm function directly with a single file.""" + # Create a test file + test_file = tmp_path / "test.qasm" + test_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate q { h q; } + qubit[2] q; + hgate q[0]; + hgate q[1]; + """ + ) + + with pytest.raises(typer.Exit) as exc_info: + unroll_qasm([str(test_file)]) + + assert exc_info.value.exit_code == 0 + + captured = capsys.readouterr() + assert "Successfully unrolled 1 file" in captured.out + assert "Success: unrolled 1 source file" in captured.out + + # Check that unrolled file was created + unrolled_file = tmp_path / "test_unrolled.qasm" + assert unrolled_file.exists() + + # Verify content was unrolled (custom gate should be expanded) + content = unrolled_file.read_text() + assert "h q[0];" in content + assert "h q[1];" in content + assert "gate hgate" not in content # Custom gate should be removed + + +def test_unroll_qasm_function_with_invalid_file(capsys, tmp_path): + """Test unroll_qasm function directly with an invalid file.""" + # Create an invalid test file + test_file = tmp_path / "invalid.qasm" + test_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + h q[2]; // Invalid: index 2 out of range + """ + ) + + with pytest.raises(typer.Exit) as exc_info: + unroll_qasm([str(test_file)]) + + assert exc_info.value.exit_code == 1 + + captured = capsys.readouterr() + assert "Failed to unroll:" in captured.out + # The error message has extra spaces, so let's normalize it + normalized_output = " ".join(captured.out.split()) + assert "Index 2 out of range for register of size 1 in qubit" in normalized_output From ec294c3f3605ae6fc58f287c21dc57c9baf0d774 Mon Sep 17 00:00:00 2001 From: vinayswamik Date: Sun, 29 Jun 2025 20:22:26 -0500 Subject: [PATCH 02/10] update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2d47eb..5efee216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Types of changes: ### Added - A new discussion template for issues in pyqasm ([#213](https://github.com/qBraid/pyqasm/pull/213)) - 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)) ### 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)) - Updated the documentation to include core features in the `README` ([#219](https://github.com/qBraid/pyqasm/pull/219)) From c451b28c88bae5bcf009c2a5951bc356cb69a688 Mon Sep 17 00:00:00 2001 From: vinayswamik Date: Sun, 29 Jun 2025 20:31:46 -0500 Subject: [PATCH 03/10] update test_cli_commands.py - code refactor --- tests/cli/test_cli_commands.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/cli/test_cli_commands.py b/tests/cli/test_cli_commands.py index a21b1b47..6c7bee63 100644 --- a/tests/cli/test_cli_commands.py +++ b/tests/cli/test_cli_commands.py @@ -296,13 +296,11 @@ def test_unroll_command_directory_overwrite(runner: CliRunner, tmp_path): original_contents = [] for i in range(2): test_file = test_dir / f"test{i}.qasm" - content = f""" + content = """ OPENQASM 3.0; include "stdgates.inc"; - gate hgate{i} q {{ h q; }} qubit[2] q; - hgate{i} q[0]; - hgate{i} q[1] + h q; """ test_file.write_text(content) original_contents.append(content) @@ -331,13 +329,11 @@ def test_unroll_command_with_skip(runner: CliRunner, tmp_path): for i in range(3): test_file = test_dir / f"test{i}.qasm" test_file.write_text( - f""" + """ OPENQASM 3.0; include "stdgates.inc"; - gate hgate{i} q {{ h q; }} qubit[2] q; - hgate{i} q[0]; - hgate{i} q[1]; + h q; """ ) From 23758ddbd96d98f7ca07b3a8a9632094e41a8afc Mon Sep 17 00:00:00 2001 From: vinayswamik Date: Sun, 29 Jun 2025 20:46:18 -0500 Subject: [PATCH 04/10] update test_cli_commands.py - code refactor --- tests/cli/test_cli_commands.py | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/tests/cli/test_cli_commands.py b/tests/cli/test_cli_commands.py index 6c7bee63..7a8a9c19 100644 --- a/tests/cli/test_cli_commands.py +++ b/tests/cli/test_cli_commands.py @@ -162,17 +162,6 @@ def test_main_help_flag(runner: CliRunner): # Unroll command tests -def test_unroll_command_help(runner: CliRunner): - """Test the `unroll` CLI command help.""" - result = runner.invoke(app, ["unroll", "--help"]) - - assert result.exit_code == 0 - assert "Unroll OpenQASM files." in result.output - assert "--skip" in result.output - assert "--overwrite" in result.output - assert "--output" in result.output - - def test_unroll_command_single_file(runner: CliRunner, tmp_path): """Test the `unroll` CLI command with a single file.""" # Create a test file @@ -389,24 +378,6 @@ def test_unroll_command_with_skip_tag(runner: CliRunner, tmp_path): assert (test_dir / "normal_unrolled.qasm").exists() assert not (test_dir / "skip_me_unrolled.qasm").exists() - -def test_unroll_command_multiple_files_with_output_error(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with multiple files and output option (should fail).""" - # Create test files - test_file1 = tmp_path / "test1.qasm" - test_file1.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") - - test_file2 = tmp_path / "test2.qasm" - test_file2.write_text("OPENQASM 3.0; qubit[1] q; x q[0];") - - result = runner.invoke( - app, ["unroll", str(test_file1), str(test_file2), "--output", "output.qasm"] - ) - - assert result.exit_code == 2 - assert "--output can only be used with a single input file" in result.output - - def test_unroll_command_with_invalid_file(runner: CliRunner, tmp_path): """Test the `unroll` CLI command with an invalid file.""" # Create an invalid test file From 4cf73661d567a235876dfd0b49b37417a9cd9f64 Mon Sep 17 00:00:00 2001 From: vinayswamik Date: Sun, 29 Jun 2025 20:51:21 -0500 Subject: [PATCH 05/10] linting --- tests/cli/test_cli_commands.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/cli/test_cli_commands.py b/tests/cli/test_cli_commands.py index 7a8a9c19..63241795 100644 --- a/tests/cli/test_cli_commands.py +++ b/tests/cli/test_cli_commands.py @@ -378,6 +378,7 @@ def test_unroll_command_with_skip_tag(runner: CliRunner, tmp_path): assert (test_dir / "normal_unrolled.qasm").exists() assert not (test_dir / "skip_me_unrolled.qasm").exists() + def test_unroll_command_with_invalid_file(runner: CliRunner, tmp_path): """Test the `unroll` CLI command with an invalid file.""" # Create an invalid test file From d6a7ac2e5005b4eb7c7d8d6ce171b90794c9268b Mon Sep 17 00:00:00 2001 From: Kapakayala Naga sai krishna vinayswami <66941388+vinayswamik@users.noreply.github.com> Date: Mon, 30 Jun 2025 02:01:53 -0500 Subject: [PATCH 06/10] Update src/pyqasm/cli/main.py Co-authored-by: Harshit Gupta --- src/pyqasm/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyqasm/cli/main.py b/src/pyqasm/cli/main.py index bc9732c8..cb67a8aa 100644 --- a/src/pyqasm/cli/main.py +++ b/src/pyqasm/cli/main.py @@ -80,7 +80,7 @@ def unroll( # pylint: disable=dangerous-default-value typer.Option( "--skip", "-s", help="Files to skip during unrolling.", callback=validate_paths_exist ), - ] = [], + ] = None, overwrite: Annotated[ bool, typer.Option("--overwrite", help="Overwrite original files instead of creating new ones."), From 0c437149b5e1de250066cf381b36c7abd48c4176 Mon Sep 17 00:00:00 2001 From: vinayswamik Date: Thu, 3 Jul 2025 03:59:25 -0500 Subject: [PATCH 07/10] Refactor unroll command and enhance error handling - Updated `unroll` command in `main.py` to accept optional parameters for `skip_files` and `overwrite`. - Introduced a new method in `QasmModule` to determine if files should be skipped based on specific tags. - Enhanced error reporting in the `unroll_qasm` function to include raw logger output for better debugging. - Added comprehensive tests for the `unroll` command, covering various scenarios including file skipping, output handling, and error cases. --- src/pyqasm/cli/main.py | 8 +- src/pyqasm/cli/unroll.py | 86 ++-- src/pyqasm/cli/validate.py | 21 +- src/pyqasm/modules/base.py | 20 + tests/cli/test_cli_commands.py | 470 -------------------- tests/cli/test_unroll_cli_commands.py | 593 ++++++++++++++++++++++++++ 6 files changed, 678 insertions(+), 520 deletions(-) create mode 100644 tests/cli/test_unroll_cli_commands.py diff --git a/src/pyqasm/cli/main.py b/src/pyqasm/cli/main.py index cb67a8aa..9433ac81 100644 --- a/src/pyqasm/cli/main.py +++ b/src/pyqasm/cli/main.py @@ -76,19 +76,21 @@ def unroll( # pylint: disable=dangerous-default-value ), ], skip_files: Annotated[ - list[str], + Optional[list[str]], typer.Option( "--skip", "-s", help="Files to skip during unrolling.", callback=validate_paths_exist ), ] = None, overwrite: Annotated[ - bool, + Optional[bool], typer.Option("--overwrite", help="Overwrite original files instead of creating new ones."), ] = False, output: Annotated[ Optional[str], typer.Option( - "--output", "-o", help="Output file path (can only be used with a single input file)." + "--output", + "-o", + help="Output file path (can only be used with a single input file).", ), ] = None, ): diff --git a/src/pyqasm/cli/unroll.py b/src/pyqasm/cli/unroll.py index 4f2c6076..8aa6b4b1 100644 --- a/src/pyqasm/cli/unroll.py +++ b/src/pyqasm/cli/unroll.py @@ -19,6 +19,8 @@ import logging import os +import tempfile +from io import StringIO from pathlib import Path from typing import Optional @@ -27,6 +29,7 @@ from pyqasm import dumps, load from pyqasm.exceptions import QasmParsingError, UnrollError, ValidationError +from pyqasm.modules.base import QasmModule logger = logging.getLogger(__name__) logger.propagate = False @@ -36,38 +39,33 @@ def unroll_qasm( src_paths: list[str], skip_files: Optional[list[str]] = None, - overwrite: bool = False, + overwrite: Optional[bool] = False, output_path: Optional[str] = None, ) -> None: """Unroll OpenQASM files""" skip_files = skip_files or [] - failed_files: list[tuple[str, Exception]] = [] + failed_files: list[tuple[str, Exception, str]] = [] successful_files: list[str] = [] console = Console() - def should_skip(filepath: str, content: str) -> bool: - if filepath in skip_files: - return True - - skip_tag = "// pyqasm: ignore" - - for line in content.splitlines(): - if skip_tag in line: - return True - if "OPENQASM" in line: - break - - return False - + # pylint: disable-next=too-many-locals, too-many-branches, too-many-statements def unroll_qasm_file(file_path: str) -> None: with open(file_path, "r", encoding="utf-8") as f: content = f.read() - if should_skip(file_path, content): + if file_path in skip_files or QasmModule.skip_qasm_files_with_tag(content, "unroll"): return + pyqasm_logger = logging.getLogger("pyqasm") + pyqasm_logger.setLevel(logging.ERROR) + pyqasm_logger.handlers.clear() # Suppress previous handlers + pyqasm_logger.propagate = False # Prevent propagation + buf = StringIO() + handler = logging.StreamHandler(buf) + handler.setLevel(logging.ERROR) + pyqasm_logger.addHandler(handler) try: module = load(file_path) module.unroll() @@ -75,9 +73,31 @@ def unroll_qasm_file(file_path: str) -> None: # Determine output file path if output_path and len(src_paths) == 1: - # Use explicitly specified output path - output_file = output_path - os.makedirs(os.path.dirname(output_file), exist_ok=True) + # If output_path is a directory, use the input filename inside that directory + if os.path.isdir(output_path): + output_file = os.path.join(output_path, os.path.basename(file_path)) + else: + output_file = output_path + if os.path.exists(output_file) and not overwrite: + raise FileExistsError( + f"Output file '{output_file}' already exists. Use --overwrite to force." + ) + output_dir = os.path.dirname(output_file) + if output_dir and not os.path.exists(output_dir): + os.makedirs(output_dir, exist_ok=True) + temp_dir = output_dir + temp_file = None + try: + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=temp_dir, delete=False + ) as tf: + temp_file = tf.name + tf.write(unrolled_content) + os.replace(temp_file, output_file) + except Exception as write_err: + if temp_file and os.path.exists(temp_file): + os.remove(temp_file) + raise write_err elif overwrite: output_file = file_path else: @@ -85,22 +105,21 @@ def unroll_qasm_file(file_path: str) -> None: path = Path(file_path) output_file = str(path.parent / f"{path.stem}_unrolled{path.suffix}") - # Write the unrolled content - with open(output_file, "w", encoding="utf-8") as f: - f.write(unrolled_content) + with open(output_file, "w", encoding="utf-8") as outf: + outf.write(unrolled_content) successful_files.append(file_path) except (ValidationError, UnrollError, QasmParsingError) as err: - failed_files.append((file_path, err)) - except Exception as uncaught_err: # pylint: disable=broad-exception-caught - logger.debug("Uncaught error in %s", file_path, exc_info=uncaught_err) - failed_files.append((file_path, uncaught_err)) + failed_files.append((file_path, err, buf.getvalue())) + except Exception as uncaught: # pylint: disable=broad-exception-caught + logger.debug("Uncaught error in %s", file_path, exc_info=uncaught) + failed_files.append((file_path, uncaught, buf.getvalue())) + finally: + pyqasm_logger.removeHandler(handler) # Clean up handler def process_files_in_directory(directory: str) -> int: count = 0 - if not os.path.isdir(directory): - return count for root, _, files in os.walk(directory): for file in files: if file.endswith(".qasm"): @@ -131,14 +150,19 @@ def process_files_in_directory(directory: str) -> int: ) if failed_files: - for file, err in failed_files: + for file, err, raw_stderr in failed_files: category = ( "".join(["-" + c.lower() if c.isupper() else c for c in type(err).__name__]) .lstrip("-") .removesuffix("-error") ) # pylint: disable-next=anomalous-backslash-in-string - console.print(f"Failed to unroll: {file} ({err}) [yellow]\\[{category}][/yellow]") + console.print("-" * 100) + console.print(f"Failed to unroll: {file}", "\n") + console.print(f"[yellow]\\[{category}-error][/yellow] -> {err}", "\n") + if raw_stderr: + console.print(raw_stderr.rstrip()) + console.print("-" * 100) num_failed = len(failed_files) s1 = "" if num_failed == 1 else "s" diff --git a/src/pyqasm/cli/validate.py b/src/pyqasm/cli/validate.py index c1ad09f3..b62494af 100644 --- a/src/pyqasm/cli/validate.py +++ b/src/pyqasm/cli/validate.py @@ -26,6 +26,7 @@ from pyqasm import load from pyqasm.exceptions import QasmParsingError, UnrollError, ValidationError +from pyqasm.modules.base import QasmModule logger = logging.getLogger(__name__) logger.propagate = False @@ -34,7 +35,7 @@ def validate_paths_exist(paths: Optional[list[str]]) -> Optional[list[str]]: """Verifies that each path in the provided list exists.""" if not paths: - return paths + return [] non_existent_paths = [path for path in paths if not os.path.exists(path)] if non_existent_paths: @@ -55,25 +56,13 @@ def validate_qasm(src_paths: list[str], skip_files: Optional[list[str]] = None) console = Console() - def should_skip(filepath: str, content: str) -> bool: - if filepath in skip_files: - return True - - skip_tag = "// pyqasm: ignore" - - for line in content.splitlines(): - if skip_tag in line: - return True - if "OPENQASM" in line: - break - - return False - def validate_qasm_file(file_path: str) -> None: with open(file_path, "r", encoding="utf-8") as f: content = f.read() - if should_skip(file_path, content): + if file_path in skip_files: + return + if QasmModule.skip_qasm_files_with_tag(content, "validate"): return try: diff --git a/src/pyqasm/modules/base.py b/src/pyqasm/modules/base.py index 3b18db71..0c3ca1c1 100644 --- a/src/pyqasm/modules/base.py +++ b/src/pyqasm/modules/base.py @@ -608,6 +608,26 @@ 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.) + + 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 + def __str__(self) -> str: """Return the string representation of the QASM program diff --git a/tests/cli/test_cli_commands.py b/tests/cli/test_cli_commands.py index 63241795..c238948c 100644 --- a/tests/cli/test_cli_commands.py +++ b/tests/cli/test_cli_commands.py @@ -31,7 +31,6 @@ ) from pyqasm.cli.main import app -from pyqasm.cli.unroll import unroll_qasm from pyqasm.cli.validate import validate_qasm CLI_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -159,472 +158,3 @@ def test_main_help_flag(runner: CliRunner): assert "Usage:" in result.output assert "validate" in result.output assert "unroll" in result.output - - -# Unroll command tests -def test_unroll_command_single_file(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with a single file.""" - # Create a test file - test_file = tmp_path / "test.qasm" - test_file.write_text( - """ - OPENQASM 3.0; - include "stdgates.inc"; - gate hgate q { h q; } - qubit[2] q; - hgate q[0]; - hgate q[1]; - """ - ) - - result = runner.invoke(app, ["unroll", str(test_file)]) - - assert result.exit_code == 0 - assert "Successfully unrolled 1 file" in result.output - assert "Success: unrolled 1 source file" in result.output - - # Check that unrolled file was created - unrolled_file = tmp_path / "test_unrolled.qasm" - assert unrolled_file.exists() - - -def test_unroll_command_single_file_with_output(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with explicit output path.""" - # Create a test file - test_file = tmp_path / "test.qasm" - test_file.write_text( - """ - OPENQASM 3.0; - include "stdgates.inc"; - gate hgate q { h q; } - qubit[2] q; - hgate q[0]; - hgate q[1]; - """ - ) - - output_file = tmp_path / "custom_output.qasm" - result = runner.invoke(app, ["unroll", str(test_file), "--output", str(output_file)]) - - assert result.exit_code == 0 - assert "Successfully unrolled 1 file" in result.output - assert output_file.exists() - - # Verify content was unrolled (custom gate should be expanded) - content = output_file.read_text() - assert "h q[0];" in content - assert "h q[1];" in content - assert "gate hgate" not in content # Custom gate should be removed - - -def test_unroll_command_single_file_overwrite(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with overwrite option.""" - # Create a test file - test_file = tmp_path / "test.qasm" - original_content = """ - OPENQASM 3.0; - include "stdgates.inc"; - gate hgate q { h q; } - qubit[2] q; - hgate q[0]; - hgate q[1]; - """ - test_file.write_text(original_content) - - result = runner.invoke(app, ["unroll", str(test_file), "--overwrite"]) - - assert result.exit_code == 0 - assert "Successfully unrolled 1 file" in result.output - - # Check that original file was overwritten - new_content = test_file.read_text() - assert new_content != original_content - assert "h q[0];" in new_content - assert "h q[1];" in new_content - assert "gate hgate" not in new_content - - -def test_unroll_command_directory(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with a directory.""" - # Create test directory with multiple files - test_dir = tmp_path / "test_dir" - test_dir.mkdir() - - # Create multiple test files - for i in range(3): - test_file = test_dir / f"test{i}.qasm" - test_file.write_text( - f""" - OPENQASM 3.0; - include "stdgates.inc"; - gate hgate{i} q {{ h q; }} - qubit[2] q; - hgate{i} q[0]; - hgate{i} q[1]; - """ - ) - - result = runner.invoke(app, ["unroll", str(test_dir)]) - - assert result.exit_code == 0 - assert "Successfully unrolled 3 files" in result.output - - # Check that unrolled files were created - for i in range(3): - unrolled_file = test_dir / f"test{i}_unrolled.qasm" - assert unrolled_file.exists() - - -def test_unroll_command_directory_overwrite(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with directory and overwrite.""" - # Create test directory with multiple files - test_dir = tmp_path / "test_dir" - test_dir.mkdir() - - # Create multiple test files - original_contents = [] - for i in range(2): - test_file = test_dir / f"test{i}.qasm" - content = """ - OPENQASM 3.0; - include "stdgates.inc"; - qubit[2] q; - h q; - """ - test_file.write_text(content) - original_contents.append(content) - - result = runner.invoke(app, ["unroll", str(test_dir), "--overwrite"]) - - assert result.exit_code == 0 - assert "Successfully unrolled 2 files" in result.output - - # Check that original files were overwritten - for i in range(2): - test_file = test_dir / f"test{i}.qasm" - new_content = test_file.read_text() - assert new_content != original_contents[i] - assert "h q[0];" in new_content - assert "h q[1];" in new_content - - -def test_unroll_command_with_skip(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with skip option.""" - # Create test directory with multiple files - test_dir = tmp_path / "test_dir" - test_dir.mkdir() - - # Create test files - for i in range(3): - test_file = test_dir / f"test{i}.qasm" - test_file.write_text( - """ - OPENQASM 3.0; - include "stdgates.inc"; - qubit[2] q; - h q; - """ - ) - - # Skip one file - skip_file = str(test_dir / "test1.qasm") - result = runner.invoke(app, ["unroll", str(test_dir), "--skip", skip_file]) - - assert result.exit_code == 0 - assert "Successfully unrolled 2 files" in result.output - - # Check that only 2 files were processed - assert (test_dir / "test0_unrolled.qasm").exists() - assert not (test_dir / "test1_unrolled.qasm").exists() - assert (test_dir / "test2_unrolled.qasm").exists() - - -def test_unroll_command_with_skip_tag(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with skip tag in file.""" - # Create test directory - test_dir = tmp_path / "test_dir" - test_dir.mkdir() - - # Create a file with skip tag - skip_file = test_dir / "skip_me.qasm" - skip_file.write_text( - """ - // pyqasm: ignore - OPENQASM 3.0; - include "stdgates.inc"; - qubit[2] q; - h q[0]; - """ - ) - - # Create a normal file - normal_file = test_dir / "normal.qasm" - normal_file.write_text( - """ - OPENQASM 3.0; - include "stdgates.inc"; - gate hgate q { h q; } - qubit[2] q; - hgate q[0]; - """ - ) - - result = runner.invoke(app, ["unroll", str(test_dir)]) - - assert result.exit_code == 0 - assert "Successfully unrolled 1 file" in result.output - - # Check that only normal file was processed - assert (test_dir / "normal_unrolled.qasm").exists() - assert not (test_dir / "skip_me_unrolled.qasm").exists() - - -def test_unroll_command_with_invalid_file(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with an invalid file.""" - # Create an invalid test file - test_file = tmp_path / "invalid.qasm" - test_file.write_text( - """ - OPENQASM 3.0; - include "stdgates.inc"; - qubit[1] q; - h q[2]; // Invalid: index 2 out of range - """ - ) - - result = runner.invoke(app, ["unroll", str(test_file)]) - - assert result.exit_code == 1 - assert "Failed to unroll:" in result.output - # The error message has extra spaces, so let's normalize it - normalized_output = " ".join(result.output.split()) - assert "Index 2 out of range for register of size 1 in qubit" in normalized_output - - -def test_unroll_command_mixed_success_and_failure(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with mixed success and failure.""" - # Create test directory - test_dir = tmp_path / "test_dir" - test_dir.mkdir() - - # Create a valid file - valid_file = test_dir / "valid.qasm" - valid_file.write_text( - """ - OPENQASM 3.0; - include "stdgates.inc"; - qubit[1] q; - h q[0]; - """ - ) - - # Create an invalid file - invalid_file = test_dir / "invalid.qasm" - invalid_file.write_text( - """ - OPENQASM 3.0; - include "stdgates.inc"; - qubit[1] q; - h q[2]; // Invalid: index 2 out of range - """ - ) - - result = runner.invoke(app, ["unroll", str(test_dir)]) - - assert result.exit_code == 1 - assert "Successfully unrolled 1 file" in result.output - assert "Failed to unroll:" in result.output - assert "Failed to unroll 1 file (checked 2 source files)" in result.output - - # Check that valid file was processed - assert (test_dir / "valid_unrolled.qasm").exists() - - -def test_unroll_command_no_files(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with no .qasm files.""" - # Create empty directory - empty_dir = tmp_path / "empty_dir" - empty_dir.mkdir() - - # Create a non-qasm file - text_file = empty_dir / "text.txt" - text_file.write_text("This is not a QASM file") - - result = runner.invoke(app, ["unroll", str(empty_dir)]) - - assert result.exit_code == 0 - assert "No .qasm files present. Nothing to do." in result.output - - -def test_unroll_command_nonexistent_file(runner: CliRunner): - """Test the `unroll` CLI command with nonexistent file.""" - result = runner.invoke(app, ["unroll", "nonexistent.qasm"]) - - assert result.exit_code == 2 - assert "does not exist" in result.output - - -def test_unroll_command_nonexistent_directory(runner: CliRunner): - """Test the `unroll` CLI command with nonexistent directory.""" - result = runner.invoke(app, ["unroll", "nonexistent_directory/"]) - assert result.exit_code == 2 - assert "does not" in result.output and "exist" in result.output - - -def test_unroll_command_output_to_nonexistent_directory(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with output to nonexistent directory.""" - # Create a test file - test_file = tmp_path / "test.qasm" - test_file.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") - - # Try to output to nonexistent directory - output_path = tmp_path / "nonexistent_dir" / "output.qasm" - result = runner.invoke(app, ["unroll", str(test_file), "--output", str(output_path)]) - - # This should succeed and create the directory - assert result.exit_code == 0 - assert output_path.exists() - - -def test_unroll_command_overwrite_and_output_precedence(runner: CliRunner, tmp_path): - """Test that --output takes precedence over --overwrite.""" - # Create a test file - test_file = tmp_path / "test.qasm" - original_content = """ - OPENQASM 3.0; - include "stdgates.inc"; - gate hgate q { h q; } - qubit[2] q; - hgate q[0]; - """ - test_file.write_text(original_content) - - output_file = tmp_path / "output.qasm" - result = runner.invoke( - app, ["unroll", str(test_file), "--overwrite", "--output", str(output_file)] - ) - - assert result.exit_code == 0 - assert "Successfully unrolled 1 file" in result.output - - # Check that original file was not changed - assert test_file.read_text() == original_content - # Check that output file was created - assert output_file.exists() - - -def test_unroll_command_mixed_inputs(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with mixed file and directory inputs.""" - # Create a test file - test_file = tmp_path / "test.qasm" - test_file.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") - - # Create a test directory - test_dir = tmp_path / "test_dir" - test_dir.mkdir() - dir_file = test_dir / "dir_test.qasm" - dir_file.write_text("OPENQASM 3.0; qubit[1] q; x q[0];") - - result = runner.invoke(app, ["unroll", str(test_file), str(test_dir)]) - - assert result.exit_code == 0 - assert "Successfully unrolled 2 files" in result.output - - # Check that both files were processed - assert (tmp_path / "test_unrolled.qasm").exists() - assert (test_dir / "dir_test_unrolled.qasm").exists() - - -def test_unroll_command_skip_multiple_files(runner: CliRunner, tmp_path): - """Test the `unroll` CLI command with multiple skip files.""" - # Create test directory - test_dir = tmp_path / "test_dir" - test_dir.mkdir() - - # Create multiple test files - for i in range(4): - test_file = test_dir / f"test{i}.qasm" - test_file.write_text(f"OPENQASM 3.0; qubit[1] q; h q[0];") - - # Skip multiple files using multiple --skip flags with full paths - result = runner.invoke( - app, - [ - "unroll", - str(test_dir), - "--skip", - str(test_dir / "test0.qasm"), - "--skip", - str(test_dir / "test2.qasm"), - ], - ) - - assert result.exit_code == 0 - assert "Successfully unrolled 2 files" in result.output - - # Check that only non-skipped files were processed - assert not (test_dir / "test0_unrolled.qasm").exists() - assert (test_dir / "test1_unrolled.qasm").exists() - assert not (test_dir / "test2_unrolled.qasm").exists() - assert (test_dir / "test3_unrolled.qasm").exists() - - -# Direct function tests for unroll_qasm -def test_unroll_qasm_function_single_file(capsys, tmp_path): - """Test unroll_qasm function directly with a single file.""" - # Create a test file - test_file = tmp_path / "test.qasm" - test_file.write_text( - """ - OPENQASM 3.0; - include "stdgates.inc"; - gate hgate q { h q; } - qubit[2] q; - hgate q[0]; - hgate q[1]; - """ - ) - - with pytest.raises(typer.Exit) as exc_info: - unroll_qasm([str(test_file)]) - - assert exc_info.value.exit_code == 0 - - captured = capsys.readouterr() - assert "Successfully unrolled 1 file" in captured.out - assert "Success: unrolled 1 source file" in captured.out - - # Check that unrolled file was created - unrolled_file = tmp_path / "test_unrolled.qasm" - assert unrolled_file.exists() - - # Verify content was unrolled (custom gate should be expanded) - content = unrolled_file.read_text() - assert "h q[0];" in content - assert "h q[1];" in content - assert "gate hgate" not in content # Custom gate should be removed - - -def test_unroll_qasm_function_with_invalid_file(capsys, tmp_path): - """Test unroll_qasm function directly with an invalid file.""" - # Create an invalid test file - test_file = tmp_path / "invalid.qasm" - test_file.write_text( - """ - OPENQASM 3.0; - include "stdgates.inc"; - qubit[1] q; - h q[2]; // Invalid: index 2 out of range - """ - ) - - with pytest.raises(typer.Exit) as exc_info: - unroll_qasm([str(test_file)]) - - assert exc_info.value.exit_code == 1 - - captured = capsys.readouterr() - assert "Failed to unroll:" in captured.out - # The error message has extra spaces, so let's normalize it - normalized_output = " ".join(captured.out.split()) - assert "Index 2 out of range for register of size 1 in qubit" in normalized_output diff --git a/tests/cli/test_unroll_cli_commands.py b/tests/cli/test_unroll_cli_commands.py new file mode 100644 index 00000000..512f19b3 --- /dev/null +++ b/tests/cli/test_unroll_cli_commands.py @@ -0,0 +1,593 @@ +# 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 unit tests for PyQASM Unroll CLI commands. + +""" + +import os +import re +import warnings + +import pytest +import typer +from typer.testing import CliRunner + +warnings.filterwarnings( + "ignore", "Importing 'pyqasm' outside a proper installation.", category=UserWarning +) + +from pyqasm.cli.main import app +from pyqasm.cli.unroll import unroll_qasm + + +@pytest.fixture +def runner(): + """Fixture to create a CLI runner.""" + return CliRunner() + + +def test_unroll_command_single_file(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with a single file.""" + # Create a test file + test_file = tmp_path / "test.qasm" + test_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate q { h q; } + qubit[2] q; + hgate q[0]; + hgate q[1]; + """ + ) + + result = runner.invoke(app, ["unroll", str(test_file)]) + + assert result.exit_code == 0 + assert "Successfully unrolled 1 file" in result.output + assert "Success: unrolled 1 source file" in result.output + + # Check that unrolled file was created + unrolled_file = tmp_path / "test_unrolled.qasm" + assert unrolled_file.exists() + + +def test_unroll_command_single_file_with_output(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with explicit output path.""" + # Create a test file + test_file = tmp_path / "test.qasm" + test_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate q { h q; } + qubit[2] q; + hgate q[0]; + hgate q[1]; + """ + ) + + output_file = tmp_path / "custom_output.qasm" + result = runner.invoke(app, ["unroll", str(test_file), "--output", str(output_file)]) + + assert result.exit_code == 0 + assert "Successfully unrolled 1 file" in result.output + assert output_file.exists() + + # Verify content was unrolled (custom gate should be expanded) + content = output_file.read_text() + assert "h q[0];" in content + assert "h q[1];" in content + assert "gate hgate" not in content # Custom gate should be removed + + +def test_unroll_command_single_file_overwrite(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with overwrite option.""" + # Create a test file + test_file = tmp_path / "test.qasm" + original_content = """ + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate q { h q; } + qubit[2] q; + hgate q[0]; + hgate q[1]; + """ + test_file.write_text(original_content) + + result = runner.invoke(app, ["unroll", str(test_file), "--overwrite"]) + + assert result.exit_code == 0 + assert "Successfully unrolled 1 file" in result.output + + # Check that original file was overwritten + new_content = test_file.read_text() + assert new_content != original_content + assert "h q[0];" in new_content + assert "h q[1];" in new_content + assert "gate hgate" not in new_content + + +def test_unroll_command_directory(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with a directory.""" + # Create test directory with multiple files + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + # Create multiple test files + for i in range(3): + test_file = test_dir / f"test{i}.qasm" + test_file.write_text( + f""" + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate{i} q {{ h q; }} + qubit[2] q; + hgate{i} q[0]; + hgate{i} q[1]; + """ + ) + + result = runner.invoke(app, ["unroll", str(test_dir)]) + + assert result.exit_code == 0 + assert "Successfully unrolled 3 files" in result.output + + # Check that unrolled files were created + for i in range(3): + unrolled_file = test_dir / f"test{i}_unrolled.qasm" + assert unrolled_file.exists() + + +def test_unroll_command_directory_overwrite(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with directory and overwrite.""" + # Create test directory with multiple files + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + # Create multiple test files + original_contents = [] + for i in range(2): + test_file = test_dir / f"test{i}.qasm" + content = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[2] q; + h q; + """ + test_file.write_text(content) + original_contents.append(content) + + result = runner.invoke(app, ["unroll", str(test_dir), "--overwrite"]) + + assert result.exit_code == 0 + assert "Successfully unrolled 2 files" in result.output + + # Check that original files were overwritten + for i in range(2): + test_file = test_dir / f"test{i}.qasm" + new_content = test_file.read_text() + assert new_content != original_contents[i] + assert "h q[0];" in new_content + assert "h q[1];" in new_content + + +def test_unroll_command_with_skip(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with skip option.""" + # Create test directory with multiple files + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + # Create test files + for i in range(3): + test_file = test_dir / f"test{i}.qasm" + test_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[2] q; + h q; + """ + ) + + # Skip one file + skip_file = str(test_dir / "test1.qasm") + result = runner.invoke(app, ["unroll", str(test_dir), "--skip", skip_file]) + + assert result.exit_code == 0 + assert "Successfully unrolled 2 files" in result.output + + # Check that only 2 files were processed + assert (test_dir / "test0_unrolled.qasm").exists() + assert not (test_dir / "test1_unrolled.qasm").exists() + assert (test_dir / "test2_unrolled.qasm").exists() + + +def test_unroll_command_with_skip_tag(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with skip tag in file.""" + # Create test directory + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + # Create a file with skip tag + skip_file = test_dir / "skip_me.qasm" + skip_file.write_text( + """ + // pyqasm disable: unroll + OPENQASM 3.0; + include "stdgates.inc"; + qubit[2] q; + h q[0]; + """ + ) + + # Create a normal file + normal_file = test_dir / "normal.qasm" + normal_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate q { h q; } + qubit[2] q; + hgate q[0]; + """ + ) + + result = runner.invoke(app, ["unroll", str(test_dir)]) + + assert result.exit_code == 0 + assert "Successfully unrolled 1 file" in result.output + + # Check that only normal file was processed + assert (test_dir / "normal_unrolled.qasm").exists() + assert not (test_dir / "skip_me_unrolled.qasm").exists() + + +def test_unroll_command_with_invalid_file(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with an invalid file.""" + # Create an invalid test file + test_file = tmp_path / "invalid.qasm" + test_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + h q[2]; // Invalid: index 2 out of range + """ + ) + + result = runner.invoke(app, ["unroll", str(test_file)]) + + assert result.exit_code == 1 + assert "Failed to unroll:" in result.output + # The error message has extra spaces, so let's normalize it + normalized_output = " ".join(result.output.split()) + assert "Index 2 out of range for register of size 1 in qubit" in normalized_output + + +def test_unroll_command_mixed_success_and_failure(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with mixed success and failure.""" + # Create test directory + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + # Create a valid file + valid_file = test_dir / "valid.qasm" + valid_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + h q[0]; + """ + ) + + # Create an invalid file + invalid_file = test_dir / "invalid.qasm" + invalid_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + h q[2]; // Invalid: index 2 out of range + """ + ) + + result = runner.invoke(app, ["unroll", str(test_dir)]) + + assert result.exit_code == 1 + assert "Successfully unrolled 1 file" in result.output + assert "Failed to unroll:" in result.output + assert "Failed to unroll 1 file (checked 2 source files)" in result.output + + # Check that valid file was processed + assert (test_dir / "valid_unrolled.qasm").exists() + + +def test_unroll_command_no_files(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with no .qasm files.""" + # Create empty directory + empty_dir = tmp_path / "empty_dir" + empty_dir.mkdir() + + # Create a non-qasm file + text_file = empty_dir / "text.txt" + text_file.write_text("This is not a QASM file") + + result = runner.invoke(app, ["unroll", str(empty_dir)]) + + assert result.exit_code == 0 + assert "No .qasm files present. Nothing to do." in result.output + + +def test_unroll_command_nonexistent_file(runner: CliRunner): + """Test the `unroll` CLI command with nonexistent file.""" + result = runner.invoke(app, ["unroll", "nonexistent.qasm"]) + + assert result.exit_code == 2 + assert "does not exist" in result.output + + +def test_unroll_command_nonexistent_directory(runner: CliRunner): + """Test the `unroll` CLI command with nonexistent directory.""" + result = runner.invoke(app, ["unroll", "nonexistent_directory/"]) + assert result.exit_code == 2 + assert "does not" in result.output and "exist" in result.output + + +def test_unroll_command_output_to_nonexistent_directory(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with output to nonexistent directory.""" + # Create a test file + test_file = tmp_path / "test.qasm" + test_file.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") + + # Try to output to nonexistent directory + output_path = tmp_path / "nonexistent_dir" / "output.qasm" + result = runner.invoke(app, ["unroll", str(test_file), "--output", str(output_path)]) + + # This should succeed and create the directory + assert result.exit_code == 0 + assert output_path.exists() + + +def test_unroll_command_overwrite_and_output_precedence(runner: CliRunner, tmp_path): + """Test that --output takes precedence over --overwrite.""" + # Create a test file + test_file = tmp_path / "test.qasm" + original_content = """ + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate q { h q; } + qubit[2] q; + hgate q[0]; + """ + test_file.write_text(original_content) + + output_file = tmp_path / "output.qasm" + result = runner.invoke( + app, ["unroll", str(test_file), "--overwrite", "--output", str(output_file)] + ) + + assert result.exit_code == 0 + assert "Successfully unrolled 1 file" in result.output + + # Check that original file was not changed + assert test_file.read_text() == original_content + # Check that output file was created + assert output_file.exists() + + +def test_unroll_command_mixed_inputs(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with mixed file and directory inputs.""" + # Create a test file + test_file = tmp_path / "test.qasm" + test_file.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") + + # Create a test directory + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + dir_file = test_dir / "dir_test.qasm" + dir_file.write_text("OPENQASM 3.0; qubit[1] q; x q[0];") + + result = runner.invoke(app, ["unroll", str(test_file), str(test_dir)]) + + assert result.exit_code == 0 + assert "Successfully unrolled 2 files" in result.output + + # Check that both files were processed + assert (tmp_path / "test_unrolled.qasm").exists() + assert (test_dir / "dir_test_unrolled.qasm").exists() + + +def test_unroll_command_skip_multiple_files(runner: CliRunner, tmp_path): + """Test the `unroll` CLI command with multiple skip files.""" + # Create test directory + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + # Create multiple test files + for i in range(4): + test_file = test_dir / f"test{i}.qasm" + test_file.write_text(f"OPENQASM 3.0; qubit[1] q; h q[0];") + + # Skip multiple files using multiple --skip flags with full paths + result = runner.invoke( + app, + [ + "unroll", + str(test_dir), + "--skip", + str(test_dir / "test0.qasm"), + "--skip", + str(test_dir / "test2.qasm"), + ], + ) + + assert result.exit_code == 0 + assert "Successfully unrolled 2 files" in result.output + + # Check that only non-skipped files were processed + assert not (test_dir / "test0_unrolled.qasm").exists() + assert (test_dir / "test1_unrolled.qasm").exists() + assert not (test_dir / "test2_unrolled.qasm").exists() + assert (test_dir / "test3_unrolled.qasm").exists() + + +# Direct function tests for unroll_qasm +def test_unroll_qasm_function_single_file(capsys, tmp_path): + """Test unroll_qasm function directly with a single file.""" + # Create a test file + test_file = tmp_path / "test.qasm" + test_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + gate hgate q { h q; } + qubit[2] q; + hgate q[0]; + hgate q[1]; + """ + ) + + with pytest.raises(typer.Exit) as exc_info: + unroll_qasm([str(test_file)]) + + assert exc_info.value.exit_code == 0 + + captured = capsys.readouterr() + assert "Successfully unrolled 1 file" in captured.out + assert "Success: unrolled 1 source file" in captured.out + + # Check that unrolled file was created + unrolled_file = tmp_path / "test_unrolled.qasm" + assert unrolled_file.exists() + + # Verify content was unrolled (custom gate should be expanded) + content = unrolled_file.read_text() + assert "h q[0];" in content + assert "h q[1];" in content + assert "gate hgate" not in content # Custom gate should be removed + + +def test_unroll_qasm_function_with_invalid_file(capsys, tmp_path): + """Test unroll_qasm function directly with an invalid file.""" + # Create an invalid test file + test_file = tmp_path / "invalid.qasm" + test_file.write_text( + """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + h q[2]; // Invalid: index 2 out of range + """ + ) + + with pytest.raises(typer.Exit) as exc_info: + unroll_qasm([str(test_file)]) + + assert exc_info.value.exit_code == 1 + + captured = capsys.readouterr() + assert "Failed to unroll:" in captured.out + # The error message has extra spaces, so let's normalize it + normalized_output = " ".join(captured.out.split()) + assert "Index 2 out of range for register of size 1 in qubit" in normalized_output + + +def test_unroll_command_reports_logger_error_output(runner: CliRunner, tmp_path): + """Test that logger error output is shown in CLI output when unroll fails.""" + # Create an invalid QASM file (e.g., missing semicolon) + invalid_file = tmp_path / "invalid.qasm" + invalid_file.write_text( + """ + OPENQASM 3.0 + qubit[2] q + h q[0] + """ + ) + + result = runner.invoke(app, ["unroll", str(invalid_file)]) + + # Should fail with exit code 1 + assert result.exit_code == 1 + assert "Failed to unroll" in result.output + + +def test_unroll_output_file_exists_no_overwrite(runner: CliRunner, tmp_path): + test_file = tmp_path / "test.qasm" + test_file.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") + output_file = test_file + result = runner.invoke(app, ["unroll", str(test_file), "--output", str(output_file)]) + assert result.exit_code != 0 + assert "already exists" in result.output + + +def test_unroll_output_path_is_directory(runner: CliRunner, tmp_path): + test_file = tmp_path / "test.qasm" + test_file.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") + output_dir = tmp_path / "outdir" + output_dir.mkdir() + result = runner.invoke(app, ["unroll", str(test_file), "--output", str(output_dir)]) + assert result.exit_code == 0 + assert "Successfully unrolled 1 file" in result.output + assert "Success: unrolled 1 source file" in result.output + + +def test_unroll_output_path_with_multiple_inputs(runner: CliRunner, tmp_path): + test_file1 = tmp_path / "test1.qasm" + test_file2 = tmp_path / "test2.qasm" + test_file1.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") + test_file2.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") + output_file = tmp_path / "output.qasm" + result = runner.invoke( + app, ["unroll", str(test_file1), str(test_file2), "--output", str(output_file)] + ) + assert result.exit_code != 0 + assert "only be used with a single input file" in result.output + + +def test_unroll_skip_file_not_exist(runner: CliRunner, tmp_path): + test_file = tmp_path / "test.qasm" + test_file.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") + skip_file = tmp_path / "not_exist.qasm" + result = runner.invoke(app, ["unroll", str(test_file), "--skip", str(skip_file)]) + # Should still process the real file, skip does not crash + assert result.exit_code == 2 + assert "does not exist" in result.output + + +def test_unroll_non_qasm_file(runner: CliRunner, tmp_path): + test_file = tmp_path / "test.txt" + test_file.write_text("This is not a QASM file") + result = runner.invoke(app, ["unroll", str(test_file)]) + # Should not process, should not create _unrolled file + assert result.exit_code == 0 or result.exit_code == 2 + unrolled_file = tmp_path / "test_unrolled.txt" + assert not unrolled_file.exists() + + +def test_unroll_nested_directories(runner: CliRunner, tmp_path): + root_dir = tmp_path / "root" + sub_dir = root_dir / "sub" + sub_dir.mkdir(parents=True) + file1 = root_dir / "a.qasm" + file2 = sub_dir / "b.qasm" + file1.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") + file2.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") + result = runner.invoke(app, ["unroll", str(root_dir)]) + assert result.exit_code == 0 + assert (root_dir / "a_unrolled.qasm").exists() + assert (sub_dir / "b_unrolled.qasm").exists() From a42d3b8032eb503fd80adb612ddf3b7289db4da8 Mon Sep 17 00:00:00 2001 From: vinayswamik Date: Thu, 3 Jul 2025 04:31:19 -0500 Subject: [PATCH 08/10] code refactor --- tests/cli/test_unroll_cli_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli/test_unroll_cli_commands.py b/tests/cli/test_unroll_cli_commands.py index 512f19b3..ef857bfe 100644 --- a/tests/cli/test_unroll_cli_commands.py +++ b/tests/cli/test_unroll_cli_commands.py @@ -531,7 +531,7 @@ def test_unroll_output_file_exists_no_overwrite(runner: CliRunner, tmp_path): test_file.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") output_file = test_file result = runner.invoke(app, ["unroll", str(test_file), "--output", str(output_file)]) - assert result.exit_code != 0 + assert result.exit_code == 1 assert "already exists" in result.output From aefbf677560d74e8c6d7216797cfd26925e1f59b Mon Sep 17 00:00:00 2001 From: vinayswamik Date: Thu, 3 Jul 2025 04:39:25 -0500 Subject: [PATCH 09/10] linting --- src/pyqasm/cli/unroll.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pyqasm/cli/unroll.py b/src/pyqasm/cli/unroll.py index 8aa6b4b1..4588739d 100644 --- a/src/pyqasm/cli/unroll.py +++ b/src/pyqasm/cli/unroll.py @@ -79,9 +79,10 @@ def unroll_qasm_file(file_path: str) -> None: else: output_file = output_path if os.path.exists(output_file) and not overwrite: - raise FileExistsError( - f"Output file '{output_file}' already exists. Use --overwrite to force." + console.print( + "Output file '{output_file}' already exists. Use --overwrite to force." ) + raise typer.Exit(1) output_dir = os.path.dirname(output_file) if output_dir and not os.path.exists(output_dir): os.makedirs(output_dir, exist_ok=True) From 33b2e157b5e7eb38a3af99294370c0b248cd8a41 Mon Sep 17 00:00:00 2001 From: vinayswamik Date: Fri, 4 Jul 2025 03:07:28 -0500 Subject: [PATCH 10/10] code refactor --- src/pyqasm/cli/unroll.py | 26 +++++++++++++------------- src/pyqasm/cli/validate.py | 5 +++++ tests/cli/test_unroll_cli_commands.py | 17 +++++++---------- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/pyqasm/cli/unroll.py b/src/pyqasm/cli/unroll.py index 4588739d..d50e4ab3 100644 --- a/src/pyqasm/cli/unroll.py +++ b/src/pyqasm/cli/unroll.py @@ -55,7 +55,10 @@ def unroll_qasm_file(file_path: str) -> None: with open(file_path, "r", encoding="utf-8") as f: content = f.read() - if file_path in skip_files or QasmModule.skip_qasm_files_with_tag(content, "unroll"): + if file_path in skip_files: + return + if QasmModule.skip_qasm_files_with_tag(content, "unroll"): + skip_files.append(file_path) return pyqasm_logger = logging.getLogger("pyqasm") @@ -137,11 +140,9 @@ def process_files_in_directory(directory: str) -> int: unroll_qasm_file(item) checked += 1 - checked -= len(skip_files) - - if checked == 0: - console.print("No .qasm files present. Nothing to do.") - raise typer.Exit(0) + loaded_files = len(skip_files) + len(successful_files) + len(failed_files) + if (checked - loaded_files) == len(src_paths) or checked == 0: + console.print("[red]No .qasm files present. Nothing to do.[/red]") # Report results if successful_files: @@ -150,6 +151,10 @@ def process_files_in_directory(directory: str) -> int: f"[green]Successfully unrolled {len(successful_files)} file{s_success}[/green]" ) + if skip_files: + skiped = "" if len(skip_files) == 1 else "s" + console.print(f"[yellow]Skipped {len(skip_files)} file{skiped}[/yellow]") + if failed_files: for file, err, raw_stderr in failed_files: category = ( @@ -167,12 +172,7 @@ def process_files_in_directory(directory: str) -> int: num_failed = len(failed_files) s1 = "" if num_failed == 1 else "s" - console.print( - f"[red]Failed to unroll {num_failed} file{s1} " - f"(checked {checked} source file{'s' if checked != 1 else ''})[/red]" - ) - raise typer.Exit(1) + console.print(f"[red]Failed to unroll {num_failed} file{s1}[/red]") - s_checked = "" if checked == 1 else "s" - console.print(f"[green]Success: unrolled {checked} source file{s_checked}[/green]") + console.print(f"[green]Checked {checked} source file{'s' if checked != 1 else ''}[/green]") raise typer.Exit(0) diff --git a/src/pyqasm/cli/validate.py b/src/pyqasm/cli/validate.py index b62494af..c8e7a2f2 100644 --- a/src/pyqasm/cli/validate.py +++ b/src/pyqasm/cli/validate.py @@ -63,6 +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"): + skip_files.append(file_path) return try: @@ -100,6 +101,10 @@ def process_files_in_directory(directory: str) -> int: console.print("No .qasm files present. Nothing to do.") raise typer.Exit(0) + if skip_files: + skiped = "" if len(skip_files) == 1 else "s" + console.print(f"[yellow]Skipped {len(skip_files)} file{skiped}[/yellow]") + s_checked = "" if checked == 1 else "s" if failed_files: for file, err in failed_files: diff --git a/tests/cli/test_unroll_cli_commands.py b/tests/cli/test_unroll_cli_commands.py index ef857bfe..32ffb19d 100644 --- a/tests/cli/test_unroll_cli_commands.py +++ b/tests/cli/test_unroll_cli_commands.py @@ -58,7 +58,7 @@ def test_unroll_command_single_file(runner: CliRunner, tmp_path): assert result.exit_code == 0 assert "Successfully unrolled 1 file" in result.output - assert "Success: unrolled 1 source file" in result.output + assert "Checked 1 source file" in result.output # Check that unrolled file was created unrolled_file = tmp_path / "test_unrolled.qasm" @@ -249,7 +249,7 @@ def test_unroll_command_with_skip_tag(runner: CliRunner, tmp_path): result = runner.invoke(app, ["unroll", str(test_dir)]) assert result.exit_code == 0 - assert "Successfully unrolled 1 file" in result.output + assert "Skipped 1 file" in result.output # Check that only normal file was processed assert (test_dir / "normal_unrolled.qasm").exists() @@ -271,7 +271,6 @@ def test_unroll_command_with_invalid_file(runner: CliRunner, tmp_path): result = runner.invoke(app, ["unroll", str(test_file)]) - assert result.exit_code == 1 assert "Failed to unroll:" in result.output # The error message has extra spaces, so let's normalize it normalized_output = " ".join(result.output.split()) @@ -308,10 +307,10 @@ def test_unroll_command_mixed_success_and_failure(runner: CliRunner, tmp_path): result = runner.invoke(app, ["unroll", str(test_dir)]) - assert result.exit_code == 1 assert "Successfully unrolled 1 file" in result.output assert "Failed to unroll:" in result.output - assert "Failed to unroll 1 file (checked 2 source files)" in result.output + assert "Failed to unroll 1 file" in result.output + assert "Checked 2 source files" in result.output # Check that valid file was processed assert (test_dir / "valid_unrolled.qasm").exists() @@ -469,7 +468,7 @@ def test_unroll_qasm_function_single_file(capsys, tmp_path): captured = capsys.readouterr() assert "Successfully unrolled 1 file" in captured.out - assert "Success: unrolled 1 source file" in captured.out + assert "Checked 1 source file" in captured.out # Check that unrolled file was created unrolled_file = tmp_path / "test_unrolled.qasm" @@ -498,7 +497,7 @@ def test_unroll_qasm_function_with_invalid_file(capsys, tmp_path): with pytest.raises(typer.Exit) as exc_info: unroll_qasm([str(test_file)]) - assert exc_info.value.exit_code == 1 + assert exc_info.value.exit_code == 0 captured = capsys.readouterr() assert "Failed to unroll:" in captured.out @@ -522,7 +521,6 @@ def test_unroll_command_reports_logger_error_output(runner: CliRunner, tmp_path) result = runner.invoke(app, ["unroll", str(invalid_file)]) # Should fail with exit code 1 - assert result.exit_code == 1 assert "Failed to unroll" in result.output @@ -531,7 +529,6 @@ def test_unroll_output_file_exists_no_overwrite(runner: CliRunner, tmp_path): test_file.write_text("OPENQASM 3.0; qubit[1] q; h q[0];") output_file = test_file result = runner.invoke(app, ["unroll", str(test_file), "--output", str(output_file)]) - assert result.exit_code == 1 assert "already exists" in result.output @@ -543,7 +540,7 @@ def test_unroll_output_path_is_directory(runner: CliRunner, tmp_path): result = runner.invoke(app, ["unroll", str(test_file), "--output", str(output_dir)]) assert result.exit_code == 0 assert "Successfully unrolled 1 file" in result.output - assert "Success: unrolled 1 source file" in result.output + assert "Checked 1 source file" in result.output def test_unroll_output_path_with_multiple_inputs(runner: CliRunner, tmp_path):