Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
37 changes: 37 additions & 0 deletions src/pyqasm/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
178 changes: 178 additions & 0 deletions src/pyqasm/cli/unroll.py
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 10 additions & 16 deletions src/pyqasm/cli/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions src/pyqasm/modules/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions tests/cli/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading