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)) diff --git a/src/pyqasm/cli/main.py b/src/pyqasm/cli/main.py index b70d9de4..9433ac81 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,41 @@ 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[ + Optional[list[str]], + typer.Option( + "--skip", "-s", help="Files to skip during unrolling.", callback=validate_paths_exist + ), + ] = None, + overwrite: Annotated[ + 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).", + ), + ] = 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..d50e4ab3 --- /dev/null +++ b/src/pyqasm/cli/unroll.py @@ -0,0 +1,178 @@ +# 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 +import tempfile +from io import StringIO +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 +from pyqasm.modules.base import QasmModule + +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: Optional[bool] = False, + output_path: Optional[str] = None, +) -> None: + """Unroll OpenQASM files""" + skip_files = skip_files or [] + + failed_files: list[tuple[str, Exception, str]] = [] + successful_files: list[str] = [] + + console = Console() + + # 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 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") + 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() + unrolled_content = dumps(module) + + # Determine output file path + if output_path and len(src_paths) == 1: + # 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: + 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) + 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: + # Create new file with _unrolled suffix + path = Path(file_path) + output_file = str(path.parent / f"{path.stem}_unrolled{path.suffix}") + + 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, 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 + 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 + + 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: + s_success = "" if len(successful_files) == 1 else "s" + console.print( + 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 = ( + "".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("-" * 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" + console.print(f"[red]Failed to unroll {num_failed} file{s1}[/red]") + + 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 c1ad09f3..c8e7a2f2 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,14 @@ 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"): + skip_files.append(file_path) return try: @@ -111,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/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 f6e29b5e..c238948c 100644 --- a/tests/cli/test_cli_commands.py +++ b/tests/cli/test_cli_commands.py @@ -157,3 +157,4 @@ 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 diff --git a/tests/cli/test_unroll_cli_commands.py b/tests/cli/test_unroll_cli_commands.py new file mode 100644 index 00000000..32ffb19d --- /dev/null +++ b/tests/cli/test_unroll_cli_commands.py @@ -0,0 +1,590 @@ +# 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 "Checked 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 "Skipped 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 "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 "Successfully unrolled 1 file" in result.output + assert "Failed to unroll:" 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() + + +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 "Checked 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 == 0 + + 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 "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 "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 "Checked 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()