From 9f9c13fff00202eacaa3360055695cff15001429 Mon Sep 17 00:00:00 2001 From: LukeAndreesen <107073823+LukeAndreesen@users.noreply.github.com> Date: Thu, 17 Jul 2025 16:33:08 -0500 Subject: [PATCH 01/12] preliminary, naive implementation of external .inc inclusion --- src/pyqasm/entrypoint.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/pyqasm/entrypoint.py b/src/pyqasm/entrypoint.py index 5facd8f9..e9f46468 100644 --- a/src/pyqasm/entrypoint.py +++ b/src/pyqasm/entrypoint.py @@ -44,6 +44,30 @@ def load(filename: str, **kwargs) -> QasmModule: raise TypeError("Input 'filename' must be of type 'str'.") with open(filename, "r", encoding="utf-8") as file: program = file.read() + + # Insert included files conent + programs_to_insert = [] + insert_idx = -1 + program_lines = program.splitlines() + for idx, line in enumerate(program_lines): + if line.startswith("include"): + include_filename = line.split('"')[1] + if include_filename == "stdgates.inc": + continue + with open(include_filename, "r", encoding="utf-8") as include_file: + include_content = include_file.read() + programs_to_insert.append(include_content) + insert_idx = idx + + # Insert content below last include line + if programs_to_insert: + program_lines = ( + program_lines[:insert_idx + 1] + + programs_to_insert + + program_lines[insert_idx + 1:] + ) + program = "\n".join(program_lines) + return loads(program, **kwargs) @@ -67,6 +91,10 @@ def loads(program: openqasm3.ast.Program | str, **kwargs) -> QasmModule: if isinstance(program, str): try: program = openqasm3.parse(program) + for statement in program.statements: + if isinstance(statement, openqasm3.ast.Include): + # Handle includes here if necessary + print("include") except openqasm3.parser.QASM3ParsingError as err: raise ValidationError(f"Failed to parse OpenQASM string: {err}") from err elif not isinstance(program, openqasm3.ast.Program): From 917a558b45e5730747ae9cac8bdd0164f4f97adc Mon Sep 17 00:00:00 2001 From: LukeAndreesen <107073823+LukeAndreesen@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:38:36 -0500 Subject: [PATCH 02/12] include file processing - prelim implementation --- src/pyqasm/entrypoint.py | 86 +++++++++++++------ tests/qasm3/resources/qasm/custom_gates.inc | 2 + .../resources/qasm/include_custom_gates.qasm | 6 ++ tests/qasm3/test_entrypoint.py | 9 ++ 4 files changed, 75 insertions(+), 28 deletions(-) create mode 100644 tests/qasm3/resources/qasm/custom_gates.inc create mode 100644 tests/qasm3/resources/qasm/include_custom_gates.qasm diff --git a/src/pyqasm/entrypoint.py b/src/pyqasm/entrypoint.py index e9f46468..51fb9f15 100644 --- a/src/pyqasm/entrypoint.py +++ b/src/pyqasm/entrypoint.py @@ -18,6 +18,7 @@ """ from __future__ import annotations +import os from typing import TYPE_CHECKING import openqasm3 @@ -44,30 +45,7 @@ def load(filename: str, **kwargs) -> QasmModule: raise TypeError("Input 'filename' must be of type 'str'.") with open(filename, "r", encoding="utf-8") as file: program = file.read() - - # Insert included files conent - programs_to_insert = [] - insert_idx = -1 - program_lines = program.splitlines() - for idx, line in enumerate(program_lines): - if line.startswith("include"): - include_filename = line.split('"')[1] - if include_filename == "stdgates.inc": - continue - with open(include_filename, "r", encoding="utf-8") as include_file: - include_content = include_file.read() - programs_to_insert.append(include_content) - insert_idx = idx - - # Insert content below last include line - if programs_to_insert: - program_lines = ( - program_lines[:insert_idx + 1] + - programs_to_insert + - program_lines[insert_idx + 1:] - ) - program = "\n".join(program_lines) - + program = _process_include_statements(program, filename) return loads(program, **kwargs) @@ -91,10 +69,6 @@ def loads(program: openqasm3.ast.Program | str, **kwargs) -> QasmModule: if isinstance(program, str): try: program = openqasm3.parse(program) - for statement in program.statements: - if isinstance(statement, openqasm3.ast.Include): - # Handle includes here if necessary - print("include") except openqasm3.parser.QASM3ParsingError as err: raise ValidationError(f"Failed to parse OpenQASM string: {err}") from err elif not isinstance(program, openqasm3.ast.Program): @@ -147,3 +121,59 @@ def dumps(module: QasmModule) -> str: raise TypeError("Input 'module' must be of type pyqasm.modules.base.QasmModule") return str(module) + +def _process_include_statements(program: str, filename: str) -> str: + """ + Process include statements in a QASM file. + + Args: + program (str): The QASM program string. + filename (str): Path to the QASM file (for resolving relative includes). + + Returns: + str: The processed QASM program with includes injected. + + Raises: + FileNotFoundError: If an include file is not found or cannot be read. + """ + program_lines = program.splitlines() + processed_files = set() + modified = False + + for idx, line in enumerate(program_lines): + line = line.strip() + if line.startswith("include"): + # Extract include filename from quotes + try: + include_filename = line.split('"')[1] + except IndexError: + continue # Skip malformed include lines + + # Skip stdgates.inc and already processed files + if include_filename == "stdgates.inc" or include_filename in processed_files: + continue + + # Try to find include file relative to main file first, then current directory + include_paths = [ + os.path.join(os.path.dirname(filename), include_filename), # Relative to main file + include_filename # Current working directory + ] + + include_found = False + for include_path in include_paths: + try: + with open(include_path, "r", encoding="utf-8") as include_file: + include_content = include_file.read().strip() + # Replace the include line with the content + program_lines[idx] = include_content + processed_files.add(include_filename) + include_found = True + modified = True + break + except FileNotFoundError: + continue + + if not include_found: + raise FileNotFoundError(f"Include file '{include_filename}' not found.") from None + + return "\n".join(program_lines) diff --git a/tests/qasm3/resources/qasm/custom_gates.inc b/tests/qasm3/resources/qasm/custom_gates.inc new file mode 100644 index 00000000..a88f6852 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_gates.inc @@ -0,0 +1,2 @@ +gate custom1 a { h a; x a; rx(0.5) a; } gate custom2(p) b { custom1 b; ry(p) b; +} gate custom3(p, q) c, d { custom2(p) c; rz(q) c; cx c, d; } diff --git a/tests/qasm3/resources/qasm/include_custom_gates.qasm b/tests/qasm3/resources/qasm/include_custom_gates.qasm new file mode 100644 index 00000000..adcc0aaf --- /dev/null +++ b/tests/qasm3/resources/qasm/include_custom_gates.qasm @@ -0,0 +1,6 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include "custom_gates.inc"; + +qubit[2] q; +custom3(0.1, 0.2) q[0:]; diff --git a/tests/qasm3/test_entrypoint.py b/tests/qasm3/test_entrypoint.py index 93a8061a..72eb3601 100644 --- a/tests/qasm3/test_entrypoint.py +++ b/tests/qasm3/test_entrypoint.py @@ -44,6 +44,13 @@ def test_correct_module_dump(): check_unrolled_qasm(file.read(), qasm_str) os.remove(file_path) +def test_correct_include_processing(): + file_path = os.path.join(QASM_RESOURCES_DIR, "include_custom_gates.qasm") + module = load(file_path) + ref_file_path = os.path.join(QASM_RESOURCES_DIR, "custom_gate_complex.qasm") + ref_module = load(ref_file_path) + check_unrolled_qasm(dumps(module), dumps(ref_module)) + def test_incorrect_module_loading_file(): with pytest.raises(TypeError, match="Input 'filename' must be of type 'str'."): @@ -73,3 +80,5 @@ def test_incorrect_module_unroll_raises_error(): with pytest.raises(ValidationError): module = loads("OPENQASM 3.0;\n qubit q; h q[2]") module.unroll() + + From 1714cd9ff18d4f9731d18bbd76436e297acf983d Mon Sep 17 00:00:00 2001 From: LukeAndreesen <107073823+LukeAndreesen@users.noreply.github.com> Date: Thu, 24 Jul 2025 09:50:47 -0500 Subject: [PATCH 03/12] preliminary commit - still needs updates before review --- src/pyqasm/entrypoint.py | 11 ++++------- tests/qasm3/resources/qasm/include_gates_and_vars.inc | 4 ++++ .../qasm3/resources/qasm/include_gates_and_vars.qasm | 11 +++++++++++ tests/qasm3/test_entrypoint.py | 4 +++- 4 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 tests/qasm3/resources/qasm/include_gates_and_vars.inc create mode 100644 tests/qasm3/resources/qasm/include_gates_and_vars.qasm diff --git a/src/pyqasm/entrypoint.py b/src/pyqasm/entrypoint.py index 51fb9f15..3c6c023c 100644 --- a/src/pyqasm/entrypoint.py +++ b/src/pyqasm/entrypoint.py @@ -19,6 +19,7 @@ from __future__ import annotations import os +import re from typing import TYPE_CHECKING import openqasm3 @@ -138,17 +139,15 @@ def _process_include_statements(program: str, filename: str) -> str: """ program_lines = program.splitlines() processed_files = set() - modified = False for idx, line in enumerate(program_lines): line = line.strip() if line.startswith("include"): # Extract include filename from quotes - try: - include_filename = line.split('"')[1] - except IndexError: + match = re.search(r'include\s+["\']([^"\']+)["\']', line) + if not match: continue # Skip malformed include lines - + include_filename = match.group(1) # Skip stdgates.inc and already processed files if include_filename == "stdgates.inc" or include_filename in processed_files: continue @@ -168,12 +167,10 @@ def _process_include_statements(program: str, filename: str) -> str: program_lines[idx] = include_content processed_files.add(include_filename) include_found = True - modified = True break except FileNotFoundError: continue if not include_found: raise FileNotFoundError(f"Include file '{include_filename}' not found.") from None - return "\n".join(program_lines) diff --git a/tests/qasm3/resources/qasm/include_gates_and_vars.inc b/tests/qasm3/resources/qasm/include_gates_and_vars.inc new file mode 100644 index 00000000..2b0a6776 --- /dev/null +++ b/tests/qasm3/resources/qasm/include_gates_and_vars.inc @@ -0,0 +1,4 @@ +const int A = 5; +float b = 2.34; +array[float[32], 3, 2] c = {{1.1, 1.2}, {2.1,2.2}, {3.1, 3.2}}; +gate d(n) a { h a; x a; rx(n) a; } diff --git a/tests/qasm3/resources/qasm/include_gates_and_vars.qasm b/tests/qasm3/resources/qasm/include_gates_and_vars.qasm new file mode 100644 index 00000000..e38243c7 --- /dev/null +++ b/tests/qasm3/resources/qasm/include_gates_and_vars.qasm @@ -0,0 +1,11 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include "include_gates_and_vars.inc"; + +// OpenQASM3 script utilizes pre-defined variables & gates from .inc +qubit[A] q; + +for int i in [0:A-1] { + float theta = c[i % 3][i % 2]; + d(theta) q[i]; +} diff --git a/tests/qasm3/test_entrypoint.py b/tests/qasm3/test_entrypoint.py index 72eb3601..d6e2eca8 100644 --- a/tests/qasm3/test_entrypoint.py +++ b/tests/qasm3/test_entrypoint.py @@ -51,7 +51,9 @@ def test_correct_include_processing(): ref_module = load(ref_file_path) check_unrolled_qasm(dumps(module), dumps(ref_module)) - +def test_correct_include_processing_complex(): + file_path = os.path.join(QASM_RESOURCES_DIR, "include_gates_and_vars.qasm") + module = load(file_path) def test_incorrect_module_loading_file(): with pytest.raises(TypeError, match="Input 'filename' must be of type 'str'."): load(1) From bfce67e4cddaef2f3a0eff8089ea4cce13fc2cc5 Mon Sep 17 00:00:00 2001 From: LukeAndreesen <107073823+LukeAndreesen@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:26:27 -0500 Subject: [PATCH 04/12] basic support for qasm files, additional tests --- src/pyqasm/entrypoint.py | 7 +++++++ tests/qasm3/resources/qasm/custom_gates.inc | 20 +++++++++++++++++-- .../qasm/custom_include/custom_gates.inc | 2 ++ .../custom_include/include_custom_gates.qasm | 6 ++++++ .../custom_include/include_gates_and_vars.inc | 4 ++++ .../include_gates_and_vars.qasm | 10 ++++++++++ .../include_gates_and_vars_ref.qasm | 14 +++++++++++++ .../custom_include/include_subroutine.qasm | 6 ++++++ .../include_subroutine_ref.qasm | 13 ++++++++++++ .../qasm/custom_include/subroutine.qasm | 10 ++++++++++ tests/qasm3/test_entrypoint.py | 19 ++++++++++++++++-- 11 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 tests/qasm3/resources/qasm/custom_include/custom_gates.inc create mode 100644 tests/qasm3/resources/qasm/custom_include/include_custom_gates.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/include_gates_and_vars.inc create mode 100644 tests/qasm3/resources/qasm/custom_include/include_gates_and_vars.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/include_gates_and_vars_ref.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/include_subroutine.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/include_subroutine_ref.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/subroutine.qasm diff --git a/src/pyqasm/entrypoint.py b/src/pyqasm/entrypoint.py index 3c6c023c..98198e37 100644 --- a/src/pyqasm/entrypoint.py +++ b/src/pyqasm/entrypoint.py @@ -163,6 +163,13 @@ def _process_include_statements(program: str, filename: str) -> str: try: with open(include_path, "r", encoding="utf-8") as include_file: include_content = include_file.read().strip() + if (os.path.splitext(include_filename)[1]) == ".qasm": + # Remove extra OPENQASM line + include_content = re.sub(r'^\s*OPENQASM\s+\d+\.\d+;\s*', '', include_content, count=1) + # remove extra "stdgates.inc" line + include_content = re.sub(r'^\s*include\s+"stdgates\.inc";\s*', '', include_content, count=1) + # TODO: recursive handling for nested includes + # Replace the include line with the content program_lines[idx] = include_content processed_files.add(include_filename) diff --git a/tests/qasm3/resources/qasm/custom_gates.inc b/tests/qasm3/resources/qasm/custom_gates.inc index a88f6852..5353c3fc 100644 --- a/tests/qasm3/resources/qasm/custom_gates.inc +++ b/tests/qasm3/resources/qasm/custom_gates.inc @@ -1,2 +1,18 @@ -gate custom1 a { h a; x a; rx(0.5) a; } gate custom2(p) b { custom1 b; ry(p) b; -} gate custom3(p, q) c, d { custom2(p) c; rz(q) c; cx c, d; } +gate custom1 a { + h a; + x a; + rx(0.5) a; +} + +gate custom2(p) b { + custom1 b; + ry(p) b; +} + +gate custom3(p, q) c, d { + custom2(p) c; + rz(q) c; + cx c, d; +} + + diff --git a/tests/qasm3/resources/qasm/custom_include/custom_gates.inc b/tests/qasm3/resources/qasm/custom_include/custom_gates.inc new file mode 100644 index 00000000..a88f6852 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/custom_gates.inc @@ -0,0 +1,2 @@ +gate custom1 a { h a; x a; rx(0.5) a; } gate custom2(p) b { custom1 b; ry(p) b; +} gate custom3(p, q) c, d { custom2(p) c; rz(q) c; cx c, d; } diff --git a/tests/qasm3/resources/qasm/custom_include/include_custom_gates.qasm b/tests/qasm3/resources/qasm/custom_include/include_custom_gates.qasm new file mode 100644 index 00000000..adcc0aaf --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_custom_gates.qasm @@ -0,0 +1,6 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include "custom_gates.inc"; + +qubit[2] q; +custom3(0.1, 0.2) q[0:]; diff --git a/tests/qasm3/resources/qasm/custom_include/include_gates_and_vars.inc b/tests/qasm3/resources/qasm/custom_include/include_gates_and_vars.inc new file mode 100644 index 00000000..2b0a6776 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_gates_and_vars.inc @@ -0,0 +1,4 @@ +const int A = 5; +float b = 2.34; +array[float[32], 3, 2] c = {{1.1, 1.2}, {2.1,2.2}, {3.1, 3.2}}; +gate d(n) a { h a; x a; rx(n) a; } diff --git a/tests/qasm3/resources/qasm/custom_include/include_gates_and_vars.qasm b/tests/qasm3/resources/qasm/custom_include/include_gates_and_vars.qasm new file mode 100644 index 00000000..596b4574 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_gates_and_vars.qasm @@ -0,0 +1,10 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include "include_gates_and_vars.inc"; + +qubit[A] q; + +for int i in [0:A-1] { + float theta = c[i % 3][i % 2]; + d(theta) q[i]; +} diff --git a/tests/qasm3/resources/qasm/custom_include/include_gates_and_vars_ref.qasm b/tests/qasm3/resources/qasm/custom_include/include_gates_and_vars_ref.qasm new file mode 100644 index 00000000..ad39c220 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_gates_and_vars_ref.qasm @@ -0,0 +1,14 @@ +OPENQASM 3.0; +include "stdgates.inc"; + +const int A = 5; +float b = 2.34; +array[float[32], 3, 2] c = {{1.1, 1.2}, {2.1,2.2}, {3.1, 3.2}}; +gate d(n) a { h a; x a; rx(n) a; } + +qubit[A] q; + +for int i in [0:A-1] { + float theta = c[i % 3][i % 2]; + d(theta) q[i]; +} diff --git a/tests/qasm3/resources/qasm/custom_include/include_subroutine.qasm b/tests/qasm3/resources/qasm/custom_include/include_subroutine.qasm new file mode 100644 index 00000000..8384afe0 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_subroutine.qasm @@ -0,0 +1,6 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include "subroutine.qasm"; + +qubit[3] q; +routine(q); \ No newline at end of file diff --git a/tests/qasm3/resources/qasm/custom_include/include_subroutine_ref.qasm b/tests/qasm3/resources/qasm/custom_include/include_subroutine_ref.qasm new file mode 100644 index 00000000..73c1f0d1 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_subroutine_ref.qasm @@ -0,0 +1,13 @@ +OPENQASM 3.0; +include "stdgates.inc"; + +def routine(qubit[3] q) { + for int[16] i in [0:2] { + h q[i]; + x q[i]; + } + cx q[0], q[1]; +} + +qubit[3] q; +routine(q); \ No newline at end of file diff --git a/tests/qasm3/resources/qasm/custom_include/subroutine.qasm b/tests/qasm3/resources/qasm/custom_include/subroutine.qasm new file mode 100644 index 00000000..966d746e --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/subroutine.qasm @@ -0,0 +1,10 @@ +OPENQASM 3.0; +include "stdgates.inc"; + +def routine(qubit[3] q) { + for int[16] i in [0:2] { + h q[i]; + x q[i]; + } + cx q[0], q[1]; +} \ No newline at end of file diff --git a/tests/qasm3/test_entrypoint.py b/tests/qasm3/test_entrypoint.py index d6e2eca8..22d9fa6e 100644 --- a/tests/qasm3/test_entrypoint.py +++ b/tests/qasm3/test_entrypoint.py @@ -44,16 +44,31 @@ def test_correct_module_dump(): check_unrolled_qasm(file.read(), qasm_str) os.remove(file_path) + def test_correct_include_processing(): - file_path = os.path.join(QASM_RESOURCES_DIR, "include_custom_gates.qasm") + file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_custom_gates.qasm") module = load(file_path) ref_file_path = os.path.join(QASM_RESOURCES_DIR, "custom_gate_complex.qasm") ref_module = load(ref_file_path) check_unrolled_qasm(dumps(module), dumps(ref_module)) + def test_correct_include_processing_complex(): - file_path = os.path.join(QASM_RESOURCES_DIR, "include_gates_and_vars.qasm") + file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_gates_and_vars.qasm") module = load(file_path) + ref_file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_gates_and_vars_ref.qasm") + ref_module = load(ref_file_path) + check_unrolled_qasm(dumps(module), dumps(ref_module)) + + +def test_include_custom_subroutine(): + file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_subroutine.qasm") + module = load(file_path) + ref_file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_subroutine_ref.qasm") + ref_module = load(ref_file_path) + check_unrolled_qasm(dumps(module), dumps(ref_module)) + + def test_incorrect_module_loading_file(): with pytest.raises(TypeError, match="Input 'filename' must be of type 'str'."): load(1) From 32824d964dc4aa3fe6c226b452a20a14f5762d39 Mon Sep 17 00:00:00 2001 From: LukeAndreesen <107073823+LukeAndreesen@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:18:08 -0500 Subject: [PATCH 05/12] formatting fixes --- src/pyqasm/entrypoint.py | 13 ++++++------- .../{include_subroutine.qasm => include_sub.qasm} | 2 +- ...ude_subroutine_ref.qasm => include_sub_ref.qasm} | 0 ...nclude_gates_and_vars.qasm => include_vars.qasm} | 2 +- ...ates_and_vars_ref.qasm => include_vars_ref.qasm} | 0 .../custom_include/{subroutine.qasm => sub.qasm} | 0 .../{include_gates_and_vars.inc => vars.inc} | 0 .../qasm3/resources/qasm/include_custom_gates.qasm | 6 ------ .../qasm3/resources/qasm/include_gates_and_vars.inc | 4 ---- .../resources/qasm/include_gates_and_vars.qasm | 11 ----------- tests/qasm3/test_entrypoint.py | 10 ++++------ 11 files changed, 12 insertions(+), 36 deletions(-) rename tests/qasm3/resources/qasm/custom_include/{include_subroutine.qasm => include_sub.qasm} (69%) rename tests/qasm3/resources/qasm/custom_include/{include_subroutine_ref.qasm => include_sub_ref.qasm} (100%) rename tests/qasm3/resources/qasm/custom_include/{include_gates_and_vars.qasm => include_vars.qasm} (77%) rename tests/qasm3/resources/qasm/custom_include/{include_gates_and_vars_ref.qasm => include_vars_ref.qasm} (100%) rename tests/qasm3/resources/qasm/custom_include/{subroutine.qasm => sub.qasm} (100%) rename tests/qasm3/resources/qasm/custom_include/{include_gates_and_vars.inc => vars.inc} (100%) delete mode 100644 tests/qasm3/resources/qasm/include_custom_gates.qasm delete mode 100644 tests/qasm3/resources/qasm/include_gates_and_vars.inc delete mode 100644 tests/qasm3/resources/qasm/include_gates_and_vars.qasm diff --git a/src/pyqasm/entrypoint.py b/src/pyqasm/entrypoint.py index 98198e37..9746526b 100644 --- a/src/pyqasm/entrypoint.py +++ b/src/pyqasm/entrypoint.py @@ -151,23 +151,23 @@ def _process_include_statements(program: str, filename: str) -> str: # Skip stdgates.inc and already processed files if include_filename == "stdgates.inc" or include_filename in processed_files: continue - # Try to find include file relative to main file first, then current directory include_paths = [ os.path.join(os.path.dirname(filename), include_filename), # Relative to main file include_filename # Current working directory ] - include_found = False for include_path in include_paths: try: with open(include_path, "r", encoding="utf-8") as include_file: include_content = include_file.read().strip() if (os.path.splitext(include_filename)[1]) == ".qasm": - # Remove extra OPENQASM line - include_content = re.sub(r'^\s*OPENQASM\s+\d+\.\d+;\s*', '', include_content, count=1) - # remove extra "stdgates.inc" line - include_content = re.sub(r'^\s*include\s+"stdgates\.inc";\s*', '', include_content, count=1) + # Remove extra OPENQASM version line to avoid duplicates + openqasm_pattern = r'^\s*OPENQASM\s+\d+\.\d+;\s*' + include_content = re.sub(openqasm_pattern, '', include_content, count=1) + # Remove extra stdgates.inc line to avoid duplicates + stdgates_pattern = r'^\s*include\s+"stdgates\.inc";\s*' + include_content = re.sub(stdgates_pattern, '', include_content, count=1) # TODO: recursive handling for nested includes # Replace the include line with the content @@ -177,7 +177,6 @@ def _process_include_statements(program: str, filename: str) -> str: break except FileNotFoundError: continue - if not include_found: raise FileNotFoundError(f"Include file '{include_filename}' not found.") from None return "\n".join(program_lines) diff --git a/tests/qasm3/resources/qasm/custom_include/include_subroutine.qasm b/tests/qasm3/resources/qasm/custom_include/include_sub.qasm similarity index 69% rename from tests/qasm3/resources/qasm/custom_include/include_subroutine.qasm rename to tests/qasm3/resources/qasm/custom_include/include_sub.qasm index 8384afe0..702c2e2b 100644 --- a/tests/qasm3/resources/qasm/custom_include/include_subroutine.qasm +++ b/tests/qasm3/resources/qasm/custom_include/include_sub.qasm @@ -1,6 +1,6 @@ OPENQASM 3.0; include "stdgates.inc"; -include "subroutine.qasm"; +include "sub.qasm"; qubit[3] q; routine(q); \ No newline at end of file diff --git a/tests/qasm3/resources/qasm/custom_include/include_subroutine_ref.qasm b/tests/qasm3/resources/qasm/custom_include/include_sub_ref.qasm similarity index 100% rename from tests/qasm3/resources/qasm/custom_include/include_subroutine_ref.qasm rename to tests/qasm3/resources/qasm/custom_include/include_sub_ref.qasm diff --git a/tests/qasm3/resources/qasm/custom_include/include_gates_and_vars.qasm b/tests/qasm3/resources/qasm/custom_include/include_vars.qasm similarity index 77% rename from tests/qasm3/resources/qasm/custom_include/include_gates_and_vars.qasm rename to tests/qasm3/resources/qasm/custom_include/include_vars.qasm index 596b4574..ee3a26a2 100644 --- a/tests/qasm3/resources/qasm/custom_include/include_gates_and_vars.qasm +++ b/tests/qasm3/resources/qasm/custom_include/include_vars.qasm @@ -1,6 +1,6 @@ OPENQASM 3.0; include "stdgates.inc"; -include "include_gates_and_vars.inc"; +include "vars.inc"; qubit[A] q; diff --git a/tests/qasm3/resources/qasm/custom_include/include_gates_and_vars_ref.qasm b/tests/qasm3/resources/qasm/custom_include/include_vars_ref.qasm similarity index 100% rename from tests/qasm3/resources/qasm/custom_include/include_gates_and_vars_ref.qasm rename to tests/qasm3/resources/qasm/custom_include/include_vars_ref.qasm diff --git a/tests/qasm3/resources/qasm/custom_include/subroutine.qasm b/tests/qasm3/resources/qasm/custom_include/sub.qasm similarity index 100% rename from tests/qasm3/resources/qasm/custom_include/subroutine.qasm rename to tests/qasm3/resources/qasm/custom_include/sub.qasm diff --git a/tests/qasm3/resources/qasm/custom_include/include_gates_and_vars.inc b/tests/qasm3/resources/qasm/custom_include/vars.inc similarity index 100% rename from tests/qasm3/resources/qasm/custom_include/include_gates_and_vars.inc rename to tests/qasm3/resources/qasm/custom_include/vars.inc diff --git a/tests/qasm3/resources/qasm/include_custom_gates.qasm b/tests/qasm3/resources/qasm/include_custom_gates.qasm deleted file mode 100644 index adcc0aaf..00000000 --- a/tests/qasm3/resources/qasm/include_custom_gates.qasm +++ /dev/null @@ -1,6 +0,0 @@ -OPENQASM 3.0; -include "stdgates.inc"; -include "custom_gates.inc"; - -qubit[2] q; -custom3(0.1, 0.2) q[0:]; diff --git a/tests/qasm3/resources/qasm/include_gates_and_vars.inc b/tests/qasm3/resources/qasm/include_gates_and_vars.inc deleted file mode 100644 index 2b0a6776..00000000 --- a/tests/qasm3/resources/qasm/include_gates_and_vars.inc +++ /dev/null @@ -1,4 +0,0 @@ -const int A = 5; -float b = 2.34; -array[float[32], 3, 2] c = {{1.1, 1.2}, {2.1,2.2}, {3.1, 3.2}}; -gate d(n) a { h a; x a; rx(n) a; } diff --git a/tests/qasm3/resources/qasm/include_gates_and_vars.qasm b/tests/qasm3/resources/qasm/include_gates_and_vars.qasm deleted file mode 100644 index e38243c7..00000000 --- a/tests/qasm3/resources/qasm/include_gates_and_vars.qasm +++ /dev/null @@ -1,11 +0,0 @@ -OPENQASM 3.0; -include "stdgates.inc"; -include "include_gates_and_vars.inc"; - -// OpenQASM3 script utilizes pre-defined variables & gates from .inc -qubit[A] q; - -for int i in [0:A-1] { - float theta = c[i % 3][i % 2]; - d(theta) q[i]; -} diff --git a/tests/qasm3/test_entrypoint.py b/tests/qasm3/test_entrypoint.py index 22d9fa6e..7242b433 100644 --- a/tests/qasm3/test_entrypoint.py +++ b/tests/qasm3/test_entrypoint.py @@ -54,17 +54,17 @@ def test_correct_include_processing(): def test_correct_include_processing_complex(): - file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_gates_and_vars.qasm") + file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_vars.qasm") module = load(file_path) - ref_file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_gates_and_vars_ref.qasm") + ref_file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_vars_ref.qasm") ref_module = load(ref_file_path) check_unrolled_qasm(dumps(module), dumps(ref_module)) def test_include_custom_subroutine(): - file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_subroutine.qasm") + file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_sub.qasm") module = load(file_path) - ref_file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_subroutine_ref.qasm") + ref_file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_sub_ref.qasm") ref_module = load(ref_file_path) check_unrolled_qasm(dumps(module), dumps(ref_module)) @@ -97,5 +97,3 @@ def test_incorrect_module_unroll_raises_error(): with pytest.raises(ValidationError): module = loads("OPENQASM 3.0;\n qubit q; h q[2]") module.unroll() - - From b5b699472a528eebf6d0ac649890e8a5367f36cb Mon Sep 17 00:00:00 2001 From: LukeAndreesen <107073823+LukeAndreesen@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:24:18 -0500 Subject: [PATCH 06/12] refactoring --- src/pyqasm/entrypoint.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/pyqasm/entrypoint.py b/src/pyqasm/entrypoint.py index 9746526b..26b24fe5 100644 --- a/src/pyqasm/entrypoint.py +++ b/src/pyqasm/entrypoint.py @@ -123,17 +123,18 @@ def dumps(module: QasmModule) -> str: return str(module) + def _process_include_statements(program: str, filename: str) -> str: """ Process include statements in a QASM file. - + Args: program (str): The QASM program string. filename (str): Path to the QASM file (for resolving relative includes). - + Returns: str: The processed QASM program with includes injected. - + Raises: FileNotFoundError: If an include file is not found or cannot be read. """ @@ -154,7 +155,7 @@ def _process_include_statements(program: str, filename: str) -> str: # Try to find include file relative to main file first, then current directory include_paths = [ os.path.join(os.path.dirname(filename), include_filename), # Relative to main file - include_filename # Current working directory + include_filename, # Current working directory ] include_found = False for include_path in include_paths: @@ -163,11 +164,11 @@ def _process_include_statements(program: str, filename: str) -> str: include_content = include_file.read().strip() if (os.path.splitext(include_filename)[1]) == ".qasm": # Remove extra OPENQASM version line to avoid duplicates - openqasm_pattern = r'^\s*OPENQASM\s+\d+\.\d+;\s*' - include_content = re.sub(openqasm_pattern, '', include_content, count=1) + openqasm_pattern = r"^\s*OPENQASM\s+\d+\.\d+;\s*" + include_content = re.sub(openqasm_pattern, "", include_content, count=1) # Remove extra stdgates.inc line to avoid duplicates stdgates_pattern = r'^\s*include\s+"stdgates\.inc";\s*' - include_content = re.sub(stdgates_pattern, '', include_content, count=1) + include_content = re.sub(stdgates_pattern, "", include_content, count=1) # TODO: recursive handling for nested includes # Replace the include line with the content From c4b3eb4d65cb8560277290f415e8c75878bb4e0e Mon Sep 17 00:00:00 2001 From: LukeAndreesen <107073823+LukeAndreesen@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:52:06 -0500 Subject: [PATCH 07/12] code-cov --- src/pyqasm/entrypoint.py | 2 +- .../qasm/custom_include/inc_not_found.qasm | 4 ++++ .../qasm/custom_include/malformed_include.qasm | 5 +++++ tests/qasm3/test_entrypoint.py | 14 ++++++++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 tests/qasm3/resources/qasm/custom_include/inc_not_found.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/malformed_include.qasm diff --git a/src/pyqasm/entrypoint.py b/src/pyqasm/entrypoint.py index 26b24fe5..5d983621 100644 --- a/src/pyqasm/entrypoint.py +++ b/src/pyqasm/entrypoint.py @@ -147,7 +147,7 @@ def _process_include_statements(program: str, filename: str) -> str: # Extract include filename from quotes match = re.search(r'include\s+["\']([^"\']+)["\']', line) if not match: - continue # Skip malformed include lines + raise ValidationError("Invalid include statement detected in QASM file.") include_filename = match.group(1) # Skip stdgates.inc and already processed files if include_filename == "stdgates.inc" or include_filename in processed_files: diff --git a/tests/qasm3/resources/qasm/custom_include/inc_not_found.qasm b/tests/qasm3/resources/qasm/custom_include/inc_not_found.qasm new file mode 100644 index 00000000..0fe4b5ec --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/inc_not_found.qasm @@ -0,0 +1,4 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include "nonexistent.inc"; +qubit q; \ No newline at end of file diff --git a/tests/qasm3/resources/qasm/custom_include/malformed_include.qasm b/tests/qasm3/resources/qasm/custom_include/malformed_include.qasm new file mode 100644 index 00000000..b59b2d19 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/malformed_include.qasm @@ -0,0 +1,5 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include missing_quotes.inc +qubit q; +h q; \ No newline at end of file diff --git a/tests/qasm3/test_entrypoint.py b/tests/qasm3/test_entrypoint.py index 7242b433..0872a3a5 100644 --- a/tests/qasm3/test_entrypoint.py +++ b/tests/qasm3/test_entrypoint.py @@ -97,3 +97,17 @@ def test_incorrect_module_unroll_raises_error(): with pytest.raises(ValidationError): module = loads("OPENQASM 3.0;\n qubit q; h q[2]") module.unroll() + + +def test_malformed_include_statement(): + """Test that malformed include statements are skipped.""" + file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "malformed_include.qasm") + with pytest.raises(ValidationError, match="Invalid include statement detected in QASM file."): + load(file_path) + + +def test_include_file_not_found(): + """Test that missing include files raise FileNotFoundError.""" + file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "inc_not_found.qasm") + with pytest.raises(FileNotFoundError, match="Include file 'nonexistent.inc' not found"): + load(file_path) From 6800c591466f9284206f23683b87b250394aeb43 Mon Sep 17 00:00:00 2001 From: LukeAndreesen <107073823+LukeAndreesen@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:06:55 -0500 Subject: [PATCH 08/12] move pre-processing to new module --- src/pyqasm/entrypoint.py | 64 +------------------------------- src/pyqasm/preprocess.py | 80 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 62 deletions(-) create mode 100644 src/pyqasm/preprocess.py diff --git a/src/pyqasm/entrypoint.py b/src/pyqasm/entrypoint.py index 5d983621..cb4eaae4 100644 --- a/src/pyqasm/entrypoint.py +++ b/src/pyqasm/entrypoint.py @@ -18,8 +18,6 @@ """ from __future__ import annotations -import os -import re from typing import TYPE_CHECKING import openqasm3 @@ -27,6 +25,7 @@ from pyqasm.exceptions import ValidationError from pyqasm.maps import SUPPORTED_QASM_VERSIONS from pyqasm.modules import Qasm2Module, Qasm3Module, QasmModule +from pyqasm.preprocess import process_include_statements if TYPE_CHECKING: import openqasm3.ast @@ -46,7 +45,7 @@ def load(filename: str, **kwargs) -> QasmModule: raise TypeError("Input 'filename' must be of type 'str'.") with open(filename, "r", encoding="utf-8") as file: program = file.read() - program = _process_include_statements(program, filename) + program = process_include_statements(program, filename) return loads(program, **kwargs) @@ -122,62 +121,3 @@ def dumps(module: QasmModule) -> str: raise TypeError("Input 'module' must be of type pyqasm.modules.base.QasmModule") return str(module) - - -def _process_include_statements(program: str, filename: str) -> str: - """ - Process include statements in a QASM file. - - Args: - program (str): The QASM program string. - filename (str): Path to the QASM file (for resolving relative includes). - - Returns: - str: The processed QASM program with includes injected. - - Raises: - FileNotFoundError: If an include file is not found or cannot be read. - """ - program_lines = program.splitlines() - processed_files = set() - - for idx, line in enumerate(program_lines): - line = line.strip() - if line.startswith("include"): - # Extract include filename from quotes - match = re.search(r'include\s+["\']([^"\']+)["\']', line) - if not match: - raise ValidationError("Invalid include statement detected in QASM file.") - include_filename = match.group(1) - # Skip stdgates.inc and already processed files - if include_filename == "stdgates.inc" or include_filename in processed_files: - continue - # Try to find include file relative to main file first, then current directory - include_paths = [ - os.path.join(os.path.dirname(filename), include_filename), # Relative to main file - include_filename, # Current working directory - ] - include_found = False - for include_path in include_paths: - try: - with open(include_path, "r", encoding="utf-8") as include_file: - include_content = include_file.read().strip() - if (os.path.splitext(include_filename)[1]) == ".qasm": - # Remove extra OPENQASM version line to avoid duplicates - openqasm_pattern = r"^\s*OPENQASM\s+\d+\.\d+;\s*" - include_content = re.sub(openqasm_pattern, "", include_content, count=1) - # Remove extra stdgates.inc line to avoid duplicates - stdgates_pattern = r'^\s*include\s+"stdgates\.inc";\s*' - include_content = re.sub(stdgates_pattern, "", include_content, count=1) - # TODO: recursive handling for nested includes - - # Replace the include line with the content - program_lines[idx] = include_content - processed_files.add(include_filename) - include_found = True - break - except FileNotFoundError: - continue - if not include_found: - raise FileNotFoundError(f"Include file '{include_filename}' not found.") from None - return "\n".join(program_lines) diff --git a/src/pyqasm/preprocess.py b/src/pyqasm/preprocess.py new file mode 100644 index 00000000..f98439bc --- /dev/null +++ b/src/pyqasm/preprocess.py @@ -0,0 +1,80 @@ +# 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. + +""" +Pre-processing prior to loading OpenQASM files as QasmModule objects. +""" +import os +import re + +from pyqasm.exceptions import ValidationError + + +def process_include_statements(program: str, filename: str) -> str: + """ + Process include statements in a QASM file. + + Args: + program (str): The QASM program string. + filename (str): Path to the QASM file (for resolving relative includes). + + Returns: + str: The processed QASM program with includes injected. + + Raises: + FileNotFoundError: If an include file is not found or cannot be read. + """ + program_lines = program.splitlines() + processed_files = set() + + for idx, line in enumerate(program_lines): + line = line.strip() + if line.startswith("include"): + # Extract include filename from quotes + match = re.search(r'include\s+["\']([^"\']+)["\']', line) + if not match: + raise ValidationError("Invalid include statement detected in QASM file.") + include_filename = match.group(1) + # Skip stdgates.inc and already processed files + if include_filename == "stdgates.inc" or include_filename in processed_files: + continue + # Try to find include file relative to main file first, then current directory + include_paths = [ + os.path.join(os.path.dirname(filename), include_filename), # Relative to main file + include_filename, # Current working directory + ] + include_found = False + for include_path in include_paths: + try: + with open(include_path, "r", encoding="utf-8") as include_file: + include_content = include_file.read().strip() + if (os.path.splitext(include_filename)[1]) == ".qasm": + # Remove extra OPENQASM version line to avoid duplicates + openqasm_pattern = r"^\s*OPENQASM\s+\d+\.\d+;\s*" + include_content = re.sub(openqasm_pattern, "", include_content, count=1) + # Remove extra stdgates.inc line to avoid duplicates + stdgates_pattern = r'^\s*include\s+"stdgates\.inc";\s*' + include_content = re.sub(stdgates_pattern, "", include_content, count=1) + # TODO: recursive handling for nested includes + + # Replace the include line with the content + program_lines[idx] = include_content + processed_files.add(include_filename) + include_found = True + break + except FileNotFoundError: + continue + if not include_found: + raise FileNotFoundError(f"Include file '{include_filename}' not found.") from None + return "\n".join(program_lines) From cc59c27d0a95176846507b4a064a50c2327ca46c Mon Sep 17 00:00:00 2001 From: LukeAndreesen <107073823+LukeAndreesen@users.noreply.github.com> Date: Tue, 29 Jul 2025 17:22:52 -0500 Subject: [PATCH 09/12] refactored pre-processing to handle recursive case. created new test cases, moved preprocessing testing to new file --- src/pyqasm/entrypoint.py | 7 +- src/pyqasm/preprocess.py | 222 ++++++++++++++---- .../qasm/custom_include/circular_import.qasm | 10 + .../qasm/custom_include/custom_gate_def.qasm | 9 + .../qasm/custom_include/custom_gates.inc | 15 +- .../include_circular_import.qasm | 5 + .../qasm/custom_include/include_nested.qasm | 6 + .../custom_include/include_nested_ref.qasm | 19 ++ .../custom_include/include_sandwiched.qasm | 6 + .../include_sandwiched_ref.qasm | 13 + .../qasm/custom_include/multi_include.qasm | 9 + .../custom_include/multi_include_ref.qasm | 19 ++ .../resources/qasm/custom_include/nested.inc | 7 + .../resources/qasm/custom_include/nested.qasm | 10 + tests/qasm3/test_entrypoint.py | 38 --- tests/qasm3/test_preprocess.py | 102 ++++++++ 16 files changed, 404 insertions(+), 93 deletions(-) create mode 100644 tests/qasm3/resources/qasm/custom_include/circular_import.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/custom_gate_def.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/include_circular_import.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/include_nested.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/include_nested_ref.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/include_sandwiched.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/include_sandwiched_ref.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/multi_include.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/multi_include_ref.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/nested.inc create mode 100644 tests/qasm3/resources/qasm/custom_include/nested.qasm create mode 100644 tests/qasm3/test_preprocess.py diff --git a/src/pyqasm/entrypoint.py b/src/pyqasm/entrypoint.py index cb4eaae4..5f46485c 100644 --- a/src/pyqasm/entrypoint.py +++ b/src/pyqasm/entrypoint.py @@ -18,6 +18,7 @@ """ from __future__ import annotations +import os from typing import TYPE_CHECKING import openqasm3 @@ -43,9 +44,9 @@ def load(filename: str, **kwargs) -> QasmModule: """ if not isinstance(filename, str): raise TypeError("Input 'filename' must be of type 'str'.") - with open(filename, "r", encoding="utf-8") as file: - program = file.read() - program = process_include_statements(program, filename) + if not os.path.isfile(filename): + raise FileNotFoundError(f"QASM file '{filename}' not found.") + program = process_include_statements(filename) return loads(program, **kwargs) diff --git a/src/pyqasm/preprocess.py b/src/pyqasm/preprocess.py index f98439bc..6c9b4bd1 100644 --- a/src/pyqasm/preprocess.py +++ b/src/pyqasm/preprocess.py @@ -17,64 +17,186 @@ """ import os import re +from dataclasses import dataclass, field +from typing import Optional from pyqasm.exceptions import ValidationError -def process_include_statements(program: str, filename: str) -> str: +@dataclass +class IncludeContext: + """Context for recursively processing include statements.""" + + visited: set[str] = field(default_factory=set) + base_file_header: list[str] = field(default_factory=list) + include_stdgates: bool = False + include_qelib1: bool = False + results: list[str] = field(default_factory=list) + + +PATTERNS = { + "openqasm": re.compile(r"^\s*OPENQASM\s+\d+(?:\.\d+)?;\s*"), + "include_stdgates": re.compile(r'^\s*include\s+"stdgates\.inc";\s*', re.MULTILINE), + "include_qelib1": re.compile(r'^\s*include\s+"qelib1\.inc";\s*', re.MULTILINE), + "include_custom": re.compile( + r'^\s*include\s+"(?!stdgates\.inc|qelib1\.inc)([^"]+)";\s*', re.MULTILINE + ), + "include_standard": re.compile( + r'^\s*include\s+"(?:stdgates\.inc|qelib1\.inc)";\s*', re.MULTILINE + ), + "include": re.compile(r'^\s*include\s+"([^"]+)";\s*', re.MULTILINE), +} + + +STD_FILES = ["stdgates.inc", "qelib1.inc"] + + +def process_include_statements(filename: str) -> str: + # First, read the file and check if it has any custom includes + with open(filename, "r", encoding="utf-8") as f: + program = f.read() + + # Check if file has any custom includes + has_custom_includes = bool(PATTERNS["include_custom"].search(program)) + + # If no custom includes, return the file as-is + if not has_custom_includes: + return program + + # Initialize context + ctx = IncludeContext() + + stack: list[tuple[str, Optional[int], Optional[int]]] = [(filename, None, None)] + _collect_headers(ctx, filename) + + while stack: + # Pop the next file to process + current_file, current_file_idx, current_file_col = stack.pop() + # Skip already processed files + if current_file in ctx.visited: + continue + # Find path to include file + valid_path = _resolve_include_path(filename, current_file) + if valid_path is None: + raise FileNotFoundError( + f"Include file '{current_file}' not found at line " + f"{current_file_idx}, column {current_file_col}" + ) + # Read the file + with open(valid_path, "r", encoding="utf-8") as f: + program = f.read() + + # Find all additional files to include + include_files = [] + # Iterate through program lines + for idx, line in enumerate(program.splitlines()): + # Search for custom include statements + if new_includes := _get_custom_includes(ctx, current_file, line, idx): + include_files.extend(new_includes) + # Check if additional standard includes are needed + if not ctx.include_stdgates and PATTERNS["include_stdgates"].match(line): + ctx.include_stdgates = True + ctx.base_file_header.append('include "stdgates.inc";') + if not ctx.include_qelib1 and PATTERNS["include_qelib1"].match(line): + ctx.include_qelib1 = True + ctx.base_file_header.append('include "qelib1.inc";') + + # Additional custom includes found, add to stack + if include_files: + # Current file contains include - reprocess after includes + stack.append((current_file, current_file_idx, current_file_col)) + stack.extend(include_files) + else: + # No more included files - add cleaned program to results + ctx.results.append(_clean_statements(program)) + ctx.visited.add(current_file) # mark as visited + + # Add original program header (without custom gates) to results + result = "\n".join(ctx.base_file_header) + "\n\n" + "\n\n".join(ctx.results) + return result + + +def _get_custom_includes( + ctx: IncludeContext, current_file: str, line: str, line_idx: int +) -> list[tuple[str, int, int]]: + """Extracts the custom include file name from a line. + + Args: + line (str): The line containing the include statement. + + Returns: + str | None: The name of the included file or None if not found. """ - Process include statements in a QASM file. + includes = [] + # Search for custom include statements + match = PATTERNS["include_custom"].match(line) + if match: + include_filename = match.group(1) + # Ignore circular imports + if include_filename.strip() == current_file.strip(): + col = line.index(include_filename) + 1 + raise ValidationError( + f"Circular include detected for file '{include_filename}'" + f" at line {line_idx + 1}, column {col}: '{line.strip()}'" + ) + # New include file to process found + if include_filename not in STD_FILES and include_filename not in ctx.visited: + col = line.index(include_filename) + 1 + includes.append((include_filename, line_idx + 1, col)) + return includes + + +def _resolve_include_path(base_file: str, file_to_include: str) -> str | None: + """Resolve the include path for a given file. Args: - program (str): The QASM program string. - filename (str): Path to the QASM file (for resolving relative includes). + base_file (str): The base file from which the include is being made. + file_to_include (str): The file to include. Returns: - str: The processed QASM program with includes injected. + str: The resolved include path. + """ + possible_paths = [os.path.join(os.path.dirname(base_file), file_to_include), file_to_include] + for path in possible_paths: + if os.path.isfile(path): + return path + return None + + +def _collect_headers(ctx: IncludeContext, base_filename: str) -> None: + """Collects the header lines (OPENQASM and standard includes) from the base file. + Args: + base_filename (str): The base filename to read. + Returns: + list[str]: A list of header lines to include at the top of the final program. + """ + + with open(base_filename, "r", encoding="utf-8") as f: + program = f.read() + + for line in program.splitlines(): + + if PATTERNS["openqasm"].match(line) or PATTERNS["include_standard"].match(line): + if PATTERNS["openqasm"].match(line) and line.strip() not in ctx.base_file_header: + # ensure openqasm line comes first + ctx.base_file_header.insert(0, line.strip()) + if line.strip() not in ctx.base_file_header: + ctx.base_file_header.append(line.strip()) + if PATTERNS["include_stdgates"].match(line): + ctx.include_stdgates = True + if PATTERNS["include_qelib1"].match(line): + ctx.include_qelib1 = True + - Raises: - FileNotFoundError: If an include file is not found or cannot be read. +def _clean_statements(program: str) -> str: + """Removes all include and OPENQASM statements from the program. + + Args: + program (str): The OpenQASM program as a string. + + Returns: + str: The program with all include statements removed. """ - program_lines = program.splitlines() - processed_files = set() - - for idx, line in enumerate(program_lines): - line = line.strip() - if line.startswith("include"): - # Extract include filename from quotes - match = re.search(r'include\s+["\']([^"\']+)["\']', line) - if not match: - raise ValidationError("Invalid include statement detected in QASM file.") - include_filename = match.group(1) - # Skip stdgates.inc and already processed files - if include_filename == "stdgates.inc" or include_filename in processed_files: - continue - # Try to find include file relative to main file first, then current directory - include_paths = [ - os.path.join(os.path.dirname(filename), include_filename), # Relative to main file - include_filename, # Current working directory - ] - include_found = False - for include_path in include_paths: - try: - with open(include_path, "r", encoding="utf-8") as include_file: - include_content = include_file.read().strip() - if (os.path.splitext(include_filename)[1]) == ".qasm": - # Remove extra OPENQASM version line to avoid duplicates - openqasm_pattern = r"^\s*OPENQASM\s+\d+\.\d+;\s*" - include_content = re.sub(openqasm_pattern, "", include_content, count=1) - # Remove extra stdgates.inc line to avoid duplicates - stdgates_pattern = r'^\s*include\s+"stdgates\.inc";\s*' - include_content = re.sub(stdgates_pattern, "", include_content, count=1) - # TODO: recursive handling for nested includes - - # Replace the include line with the content - program_lines[idx] = include_content - processed_files.add(include_filename) - include_found = True - break - except FileNotFoundError: - continue - if not include_found: - raise FileNotFoundError(f"Include file '{include_filename}' not found.") from None - return "\n".join(program_lines) + for pattern in [PATTERNS["openqasm"], PATTERNS["include"]]: + program = pattern.sub("", program) + return program diff --git a/tests/qasm3/resources/qasm/custom_include/circular_import.qasm b/tests/qasm3/resources/qasm/custom_include/circular_import.qasm new file mode 100644 index 00000000..0470702d --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/circular_import.qasm @@ -0,0 +1,10 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include "circular_import.qasm"; + +gate custom(a) p, q { + h p; + z q; + rx(a) p; + cx p,q; +} \ No newline at end of file diff --git a/tests/qasm3/resources/qasm/custom_include/custom_gate_def.qasm b/tests/qasm3/resources/qasm/custom_include/custom_gate_def.qasm new file mode 100644 index 00000000..757e79b0 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/custom_gate_def.qasm @@ -0,0 +1,9 @@ +OPENQASM 3; +include "stdgates.inc"; + +gate custom(a) p, q { + h p; + z q; + rx(a) p; + cx p,q; +} \ No newline at end of file diff --git a/tests/qasm3/resources/qasm/custom_include/custom_gates.inc b/tests/qasm3/resources/qasm/custom_include/custom_gates.inc index a88f6852..ad2b2960 100644 --- a/tests/qasm3/resources/qasm/custom_include/custom_gates.inc +++ b/tests/qasm3/resources/qasm/custom_include/custom_gates.inc @@ -1,2 +1,13 @@ -gate custom1 a { h a; x a; rx(0.5) a; } gate custom2(p) b { custom1 b; ry(p) b; -} gate custom3(p, q) c, d { custom2(p) c; rz(q) c; cx c, d; } +gate custom1 a { + h a; x a; rx(0.5) a; +} +gate custom2(p) b { + custom1 b; + ry(p) b; +} +gate custom3(p, q) c, d { + custom2(p) c; + rz(q) c; + cx c, d; +} + diff --git a/tests/qasm3/resources/qasm/custom_include/include_circular_import.qasm b/tests/qasm3/resources/qasm/custom_include/include_circular_import.qasm new file mode 100644 index 00000000..ac4623e9 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_circular_import.qasm @@ -0,0 +1,5 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include "circular_import.qasm"; + +qubit[3] q; diff --git a/tests/qasm3/resources/qasm/custom_include/include_nested.qasm b/tests/qasm3/resources/qasm/custom_include/include_nested.qasm new file mode 100644 index 00000000..40e29ff2 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_nested.qasm @@ -0,0 +1,6 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include "nested.qasm"; + +qubit[3] q; +doublenested_routine(q); \ No newline at end of file diff --git a/tests/qasm3/resources/qasm/custom_include/include_nested_ref.qasm b/tests/qasm3/resources/qasm/custom_include/include_nested_ref.qasm new file mode 100644 index 00000000..1c62b803 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_nested_ref.qasm @@ -0,0 +1,19 @@ +OPENQASM 3.0; +include "stdgates.inc"; + +def nested_routine(qubit[3] q) { + for int i in [0:2] { + h q[i]; + x q[i]; + } +} + +def doublenested_routine(qubit[3] q) { + nested_routine(q); + for int i in [0:2] { + rz(0.5) q[i]; + } +} + +qubit[3] q; +doublenested_routine(q); diff --git a/tests/qasm3/resources/qasm/custom_include/include_sandwiched.qasm b/tests/qasm3/resources/qasm/custom_include/include_sandwiched.qasm new file mode 100644 index 00000000..173163bd --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_sandwiched.qasm @@ -0,0 +1,6 @@ +OPENQASM 3.0; +include "stdgates.inc"; + +qubit[5] q; +include "custom_gate_def.qasm"; +custom(0.1+1) q[0], q[1]; diff --git a/tests/qasm3/resources/qasm/custom_include/include_sandwiched_ref.qasm b/tests/qasm3/resources/qasm/custom_include/include_sandwiched_ref.qasm new file mode 100644 index 00000000..75ae6728 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_sandwiched_ref.qasm @@ -0,0 +1,13 @@ +OPENQASM 3.0; +include "stdgates.inc"; + +gate custom(a) p, q { + h p; + z q; + rx(a) p; + cx p,q; +} + +qubit[5] q; + +custom(0.1+1) q[0], q[1]; \ No newline at end of file diff --git a/tests/qasm3/resources/qasm/custom_include/multi_include.qasm b/tests/qasm3/resources/qasm/custom_include/multi_include.qasm new file mode 100644 index 00000000..bd1f6853 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/multi_include.qasm @@ -0,0 +1,9 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include "vars.inc"; +include "custom_gate_def.qasm"; + +qubit[A] q; +custom(0.1+1) q[0], q[1]; +custom(0.5) q[2], q[3]; +custom(0.5) q[3], q[4]; diff --git a/tests/qasm3/resources/qasm/custom_include/multi_include_ref.qasm b/tests/qasm3/resources/qasm/custom_include/multi_include_ref.qasm new file mode 100644 index 00000000..47460808 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/multi_include_ref.qasm @@ -0,0 +1,19 @@ +OPENQASM 3.0; +include "stdgates.inc"; + +gate custom(a) p, q { + h p; + z q; + rx(a) p; + cx p,q; +} + +const int A = 5; +float b = 2.34; +array[float[32], 3, 2] c = {{1.1, 1.2}, {2.1,2.2}, {3.1, 3.2}}; +gate d(n) a { h a; x a; rx(n) a; } + +qubit[A] q; +custom(0.1+1) q[0], q[1]; +custom(0.5) q[2], q[3]; +custom(0.5) q[3], q[4]; diff --git a/tests/qasm3/resources/qasm/custom_include/nested.inc b/tests/qasm3/resources/qasm/custom_include/nested.inc new file mode 100644 index 00000000..c6933ab9 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/nested.inc @@ -0,0 +1,7 @@ +def nested_routine(qubit[3] q) { + for int i in [0:2] { + h q[i]; + x q[i]; + } +} + diff --git a/tests/qasm3/resources/qasm/custom_include/nested.qasm b/tests/qasm3/resources/qasm/custom_include/nested.qasm new file mode 100644 index 00000000..de92e035 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/nested.qasm @@ -0,0 +1,10 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include "nested.inc"; + +def doublenested_routine(qubit[3] q) { + nested_routine(q); + for int i in [0:2] { + rz(0.5) q[i]; + } +} \ No newline at end of file diff --git a/tests/qasm3/test_entrypoint.py b/tests/qasm3/test_entrypoint.py index 0872a3a5..93a8061a 100644 --- a/tests/qasm3/test_entrypoint.py +++ b/tests/qasm3/test_entrypoint.py @@ -45,30 +45,6 @@ def test_correct_module_dump(): os.remove(file_path) -def test_correct_include_processing(): - file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_custom_gates.qasm") - module = load(file_path) - ref_file_path = os.path.join(QASM_RESOURCES_DIR, "custom_gate_complex.qasm") - ref_module = load(ref_file_path) - check_unrolled_qasm(dumps(module), dumps(ref_module)) - - -def test_correct_include_processing_complex(): - file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_vars.qasm") - module = load(file_path) - ref_file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_vars_ref.qasm") - ref_module = load(ref_file_path) - check_unrolled_qasm(dumps(module), dumps(ref_module)) - - -def test_include_custom_subroutine(): - file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_sub.qasm") - module = load(file_path) - ref_file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "include_sub_ref.qasm") - ref_module = load(ref_file_path) - check_unrolled_qasm(dumps(module), dumps(ref_module)) - - def test_incorrect_module_loading_file(): with pytest.raises(TypeError, match="Input 'filename' must be of type 'str'."): load(1) @@ -97,17 +73,3 @@ def test_incorrect_module_unroll_raises_error(): with pytest.raises(ValidationError): module = loads("OPENQASM 3.0;\n qubit q; h q[2]") module.unroll() - - -def test_malformed_include_statement(): - """Test that malformed include statements are skipped.""" - file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "malformed_include.qasm") - with pytest.raises(ValidationError, match="Invalid include statement detected in QASM file."): - load(file_path) - - -def test_include_file_not_found(): - """Test that missing include files raise FileNotFoundError.""" - file_path = os.path.join(QASM_RESOURCES_DIR, "custom_include", "inc_not_found.qasm") - with pytest.raises(FileNotFoundError, match="Include file 'nonexistent.inc' not found"): - load(file_path) diff --git a/tests/qasm3/test_preprocess.py b/tests/qasm3/test_preprocess.py new file mode 100644 index 00000000..00350fa8 --- /dev/null +++ b/tests/qasm3/test_preprocess.py @@ -0,0 +1,102 @@ +# 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 preprocess functions. + +""" + +import os + +import pytest + +from pyqasm.entrypoint import dumps, load +from pyqasm.exceptions import ValidationError +from tests.utils import check_unrolled_qasm + +QASM_RESOURCES_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "resources", "qasm", "custom_include" +) + + +def test_correct_include_processing(): + """Test that simple custom include statements are processed correctly.""" + file_path = os.path.join(QASM_RESOURCES_DIR, "include_custom_gates.qasm") + module = load(file_path) + ref_file_path = os.path.join(os.path.dirname(QASM_RESOURCES_DIR), "custom_gate_complex.qasm") + ref_module = load(ref_file_path) + check_unrolled_qasm(dumps(module), dumps(ref_module)) + + +def test_correct_include_processing_complex(): + """Test that complex custom include statements are processed correctly.""" + file_path = os.path.join(QASM_RESOURCES_DIR, "include_vars.qasm") + module = load(file_path) + ref_file_path = os.path.join(QASM_RESOURCES_DIR, "include_vars_ref.qasm") + ref_module = load(ref_file_path) + check_unrolled_qasm(dumps(module), dumps(ref_module)) + + +def test_include_custom_subroutine(): + """Test that inclusion of custom subroutines is processed correctly.""" + file_path = os.path.join(QASM_RESOURCES_DIR, "include_sub.qasm") + module = load(file_path) + ref_file_path = os.path.join(QASM_RESOURCES_DIR, "include_sub_ref.qasm") + ref_module = load(ref_file_path) + check_unrolled_qasm(dumps(module), dumps(ref_module)) + + +def test_include_file_not_found(): + """Test that missing include files raise FileNotFoundError.""" + file_path = os.path.join(QASM_RESOURCES_DIR, "inc_not_found.qasm") + with pytest.raises( + FileNotFoundError, match="Include file 'nonexistent.inc' not found at line 3, column 1" + ): + load(file_path) + + +def test_circular_import(): + """Test that circular imports raise ValidationError.""" + file_path = os.path.join(QASM_RESOURCES_DIR, "include_circular_import.qasm") + with pytest.raises( + ValidationError, + match="Circular include detected for file 'circular_import.qasm' at line 3, column 10", + ): + load(file_path) + + +def test_multiple_includes(): + """Test that multiple include statements in a file are processed correctly.""" + file_path = os.path.join(QASM_RESOURCES_DIR, "multi_include.qasm") + module = load(file_path) + ref_file_path = os.path.join(QASM_RESOURCES_DIR, "multi_include_ref.qasm") + ref_module = load(ref_file_path) + check_unrolled_qasm(dumps(module), dumps(ref_module)) + + +def test_include_sandwiched(): + """Test that include statements sandwiched between other statements are processed correctly.""" + file_path = os.path.join(QASM_RESOURCES_DIR, "include_sandwiched.qasm") + module = load(file_path) + ref_file_path = os.path.join(QASM_RESOURCES_DIR, "include_sandwiched_ref.qasm") + ref_module = load(ref_file_path) + check_unrolled_qasm(dumps(module), dumps(ref_module)) + + +def test_nested_include_processing(): + """Test that nested include statements are recursively processed correctly.""" + file_path = os.path.join(QASM_RESOURCES_DIR, "include_nested.qasm") + module = load(file_path) + ref_file_path = os.path.join(QASM_RESOURCES_DIR, "include_nested_ref.qasm") + ref_module = load(ref_file_path) + check_unrolled_qasm(dumps(module), dumps(ref_module)) From 02c0b146677fefc242765e0d3f037009a797cc8d Mon Sep 17 00:00:00 2001 From: LukeAndreesen <107073823+LukeAndreesen@users.noreply.github.com> Date: Tue, 29 Jul 2025 19:34:06 -0500 Subject: [PATCH 10/12] fix type annotations --- src/pyqasm/preprocess.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pyqasm/preprocess.py b/src/pyqasm/preprocess.py index 6c9b4bd1..665ddb84 100644 --- a/src/pyqasm/preprocess.py +++ b/src/pyqasm/preprocess.py @@ -125,7 +125,8 @@ def _get_custom_includes( line (str): The line containing the include statement. Returns: - str | None: The name of the included file or None if not found. + list[tuple[str, int, int]]: A list of tuples containing the include filename, + line number, and column number where the include was found. """ includes = [] # Search for custom include statements @@ -154,7 +155,7 @@ def _resolve_include_path(base_file: str, file_to_include: str) -> str | None: file_to_include (str): The file to include. Returns: - str: The resolved include path. + str | None: The resolved include path, or None if not found. """ possible_paths = [os.path.join(os.path.dirname(base_file), file_to_include), file_to_include] for path in possible_paths: @@ -168,7 +169,7 @@ def _collect_headers(ctx: IncludeContext, base_filename: str) -> None: Args: base_filename (str): The base filename to read. Returns: - list[str]: A list of header lines to include at the top of the final program. + None: Modifies the context in place. """ with open(base_filename, "r", encoding="utf-8") as f: From ea46f903696e270cf765437f66bb1c3b223888d7 Mon Sep 17 00:00:00 2001 From: LukeAndreesen <107073823+LukeAndreesen@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:58:24 -0500 Subject: [PATCH 11/12] restructure to replace include at exact location, cleanup testing --- src/pyqasm/preprocess.py | 237 ++++++++++-------- .../include_sandwiched_ref.qasm | 4 +- .../custom_include/multi_include_ref.qasm | 12 +- tests/qasm3/test_preprocess.py | 64 ++--- 4 files changed, 151 insertions(+), 166 deletions(-) diff --git a/src/pyqasm/preprocess.py b/src/pyqasm/preprocess.py index 665ddb84..daf293f8 100644 --- a/src/pyqasm/preprocess.py +++ b/src/pyqasm/preprocess.py @@ -18,7 +18,6 @@ import os import re from dataclasses import dataclass, field -from typing import Optional from pyqasm.exceptions import ValidationError @@ -27,11 +26,10 @@ class IncludeContext: """Context for recursively processing include statements.""" - visited: set[str] = field(default_factory=set) base_file_header: list[str] = field(default_factory=list) include_stdgates: bool = False include_qelib1: bool = False - results: list[str] = field(default_factory=list) + visited: set[str] = field(default_factory=set) PATTERNS = { @@ -48,107 +46,124 @@ class IncludeContext: } -STD_FILES = ["stdgates.inc", "qelib1.inc"] +def process_include_statements(filename: str) -> str: + """ + Recursively processes include statements in an OpenQASM file, replacing them with the + contents of the included files. Handles circular includes and missing files. + Args: + filename (str): The path to the OpenQASM file to process. + + Returns: + str: The fully include-resolved program content. + + Raises: + FileNotFoundError: If an included file cannot be found. + ValidationError: If a circular include is detected. + """ + # Generate context for include processing + ctx = IncludeContext() -def process_include_statements(filename: str) -> str: - # First, read the file and check if it has any custom includes with open(filename, "r", encoding="utf-8") as f: program = f.read() - # Check if file has any custom includes - has_custom_includes = bool(PATTERNS["include_custom"].search(program)) + _collect_headers(ctx, program) - # If no custom includes, return the file as-is - if not has_custom_includes: + # Return program and let entrypoint handle error if missing OPENQASM line + if len(ctx.base_file_header) == 0 or "OPENQASM" not in ctx.base_file_header[0]: return program - # Initialize context - ctx = IncludeContext() + # Recursively process and replace includes in-line + result = _process_file(ctx, filename) - stack: list[tuple[str, Optional[int], Optional[int]]] = [(filename, None, None)] - _collect_headers(ctx, filename) + # Return processed file with original header + return "\n".join(ctx.base_file_header) + "\n\n" + result - while stack: - # Pop the next file to process - current_file, current_file_idx, current_file_col = stack.pop() - # Skip already processed files - if current_file in ctx.visited: - continue - # Find path to include file - valid_path = _resolve_include_path(filename, current_file) - if valid_path is None: - raise FileNotFoundError( - f"Include file '{current_file}' not found at line " - f"{current_file_idx}, column {current_file_col}" - ) - # Read the file - with open(valid_path, "r", encoding="utf-8") as f: - program = f.read() - - # Find all additional files to include - include_files = [] - # Iterate through program lines - for idx, line in enumerate(program.splitlines()): - # Search for custom include statements - if new_includes := _get_custom_includes(ctx, current_file, line, idx): - include_files.extend(new_includes) - # Check if additional standard includes are needed - if not ctx.include_stdgates and PATTERNS["include_stdgates"].match(line): - ctx.include_stdgates = True - ctx.base_file_header.append('include "stdgates.inc";') - if not ctx.include_qelib1 and PATTERNS["include_qelib1"].match(line): - ctx.include_qelib1 = True - ctx.base_file_header.append('include "qelib1.inc";') - # Additional custom includes found, add to stack - if include_files: - # Current file contains include - reprocess after includes - stack.append((current_file, current_file_idx, current_file_col)) - stack.extend(include_files) +def _process_file(ctx: IncludeContext, filepath: str) -> str: + """ + Process a single file, replacing include statements with the contents of the included files + recursively. + + Args: + ctx (IncludeContext): The context for processing includes. + filepath (str): The path to the file to process. + + Returns: + str: The fully include-resolved program content. + + Raises: + FileNotFoundError: If an included file cannot be found. + ValidationError: If a circular include is detected. + """ + filename = os.path.basename(filepath) + if filename in ctx.visited: + return "" # Already processed this file, skip to avoid circular includes + + with open(filepath, "r", encoding="utf-8") as f: + program = f.read() + + ctx.visited.add(filename) # Mark as visited to avoid looping + new_program_lines = [] + + for idx, line in enumerate(program.splitlines()): + # Search for custom include statements + match = PATTERNS["include_custom"].match(line) + if match: + include_filename = match.group(1) + # Check for circular imports + if include_filename.strip() == filename.strip(): + col = line.index(include_filename) + 1 + raise ValidationError( + f"Circular include detected for file '{include_filename}'" + f" at line {idx + 1}, column {col}: '{line.strip()}'" + ) + # Find valid path to included file + include_path = _resolve_include_path(filepath, include_filename) + if include_path is None: + raise FileNotFoundError( + f"Include file '{include_filename}' not found at line " + f"{idx+1}, column {line.find(include_filename)+1}" + ) + # Recursively process include statements within the included file + included_content = _process_file(ctx, include_path) + new_program_lines.append(included_content) else: - # No more included files - add cleaned program to results - ctx.results.append(_clean_statements(program)) - ctx.visited.add(current_file) # mark as visited + _check_for_std_includes(ctx, line) + # Skip openqasm and std includes (already in header) + if not PATTERNS["openqasm"].match(line) and not PATTERNS["include_standard"].match( + line + ): + new_program_lines.append(line) - # Add original program header (without custom gates) to results - result = "\n".join(ctx.base_file_header) + "\n\n" + "\n\n".join(ctx.results) - return result + # Join and save cleaned content for this file + cleaned = "\n".join(new_program_lines) + return cleaned # return the fully inlined program -def _get_custom_includes( - ctx: IncludeContext, current_file: str, line: str, line_idx: int -) -> list[tuple[str, int, int]]: - """Extracts the custom include file name from a line. +def _check_for_std_includes(ctx: IncludeContext, line: str) -> None: + """ + Check if the line contains standard includes and update context accordingly. Args: - line (str): The line containing the include statement. + ctx (IncludeContext): The context to update. + line (str): The line to check. Returns: - list[tuple[str, int, int]]: A list of tuples containing the include filename, - line number, and column number where the include was found. + None """ - includes = [] - # Search for custom include statements - match = PATTERNS["include_custom"].match(line) - if match: - include_filename = match.group(1) - # Ignore circular imports - if include_filename.strip() == current_file.strip(): - col = line.index(include_filename) + 1 - raise ValidationError( - f"Circular include detected for file '{include_filename}'" - f" at line {line_idx + 1}, column {col}: '{line.strip()}'" - ) - # New include file to process found - if include_filename not in STD_FILES and include_filename not in ctx.visited: - col = line.index(include_filename) + 1 - includes.append((include_filename, line_idx + 1, col)) - return includes + # Check if additional standard includes are needed + if not ctx.include_stdgates and PATTERNS["include_stdgates"].match(line): + ctx.include_stdgates = True + ctx.base_file_header.append('include "stdgates.inc";') + if not ctx.include_qelib1 and PATTERNS["include_qelib1"].match(line): + ctx.include_qelib1 = True + ctx.base_file_header.append('include "qelib1.inc";') def _resolve_include_path(base_file: str, file_to_include: str) -> str | None: - """Resolve the include path for a given file. + """ + Resolve the include path for a given file. Args: base_file (str): The base file from which the include is being made. @@ -164,40 +179,44 @@ def _resolve_include_path(base_file: str, file_to_include: str) -> str | None: return None -def _collect_headers(ctx: IncludeContext, base_filename: str) -> None: - """Collects the header lines (OPENQASM and standard includes) from the base file. +def _collect_headers(ctx: IncludeContext, program: str) -> None: + """ + Collects the header lines (OPENQASM and standard includes) from the base file. + Args: - base_filename (str): The base filename to read. + program (str): The program content to scan for headers. + Returns: None: Modifies the context in place. """ - - with open(base_filename, "r", encoding="utf-8") as f: - program = f.read() + found_openqasm = False for line in program.splitlines(): - - if PATTERNS["openqasm"].match(line) or PATTERNS["include_standard"].match(line): - if PATTERNS["openqasm"].match(line) and line.strip() not in ctx.base_file_header: - # ensure openqasm line comes first - ctx.base_file_header.insert(0, line.strip()) - if line.strip() not in ctx.base_file_header: - ctx.base_file_header.append(line.strip()) - if PATTERNS["include_stdgates"].match(line): + stripped = line.strip() + if len(stripped) == 0: + continue # skip empty lines + + if PATTERNS["openqasm"].match(line): + if stripped not in ctx.base_file_header: + # ensure OPENQASM comes first + ctx.base_file_header.insert(0, stripped) + found_openqasm = True + continue # no need to check further for this line + + if PATTERNS["include_standard"].match(line): + # Include before OPENQASM is invalid - return to handle error + if not found_openqasm: + return + # Add included library to header if not already present + if stripped not in ctx.base_file_header: + ctx.base_file_header.append(stripped) + # Check which standard includes this is + if not ctx.include_stdgates and PATTERNS["include_stdgates"].match(line): ctx.include_stdgates = True - if PATTERNS["include_qelib1"].match(line): + if not ctx.include_qelib1 and PATTERNS["include_qelib1"].match(line): ctx.include_qelib1 = True + continue - -def _clean_statements(program: str) -> str: - """Removes all include and OPENQASM statements from the program. - - Args: - program (str): The OpenQASM program as a string. - - Returns: - str: The program with all include statements removed. - """ - for pattern in [PATTERNS["openqasm"], PATTERNS["include"]]: - program = pattern.sub("", program) - return program + # If we've already found standard includes, we can stop + if ctx.include_stdgates and ctx.include_qelib1: + return diff --git a/tests/qasm3/resources/qasm/custom_include/include_sandwiched_ref.qasm b/tests/qasm3/resources/qasm/custom_include/include_sandwiched_ref.qasm index 75ae6728..44984a31 100644 --- a/tests/qasm3/resources/qasm/custom_include/include_sandwiched_ref.qasm +++ b/tests/qasm3/resources/qasm/custom_include/include_sandwiched_ref.qasm @@ -1,13 +1,11 @@ OPENQASM 3.0; include "stdgates.inc"; +qubit[5] q; gate custom(a) p, q { h p; z q; rx(a) p; cx p,q; } - -qubit[5] q; - custom(0.1+1) q[0], q[1]; \ No newline at end of file diff --git a/tests/qasm3/resources/qasm/custom_include/multi_include_ref.qasm b/tests/qasm3/resources/qasm/custom_include/multi_include_ref.qasm index 47460808..65c1b6b2 100644 --- a/tests/qasm3/resources/qasm/custom_include/multi_include_ref.qasm +++ b/tests/qasm3/resources/qasm/custom_include/multi_include_ref.qasm @@ -1,6 +1,13 @@ OPENQASM 3.0; include "stdgates.inc"; + +const int A = 5; +float b = 2.34; +array[float[32], 3, 2] c = {{1.1, 1.2}, {2.1,2.2}, {3.1, 3.2}}; +gate d(n) a { h a; x a; rx(n) a; } + + gate custom(a) p, q { h p; z q; @@ -8,11 +15,6 @@ gate custom(a) p, q { cx p,q; } -const int A = 5; -float b = 2.34; -array[float[32], 3, 2] c = {{1.1, 1.2}, {2.1,2.2}, {3.1, 3.2}}; -gate d(n) a { h a; x a; rx(n) a; } - qubit[A] q; custom(0.1+1) q[0], q[1]; custom(0.5) q[2], q[3]; diff --git a/tests/qasm3/test_preprocess.py b/tests/qasm3/test_preprocess.py index 00350fa8..c3b1b515 100644 --- a/tests/qasm3/test_preprocess.py +++ b/tests/qasm3/test_preprocess.py @@ -29,29 +29,22 @@ ) -def test_correct_include_processing(): - """Test that simple custom include statements are processed correctly.""" - file_path = os.path.join(QASM_RESOURCES_DIR, "include_custom_gates.qasm") - module = load(file_path) - ref_file_path = os.path.join(os.path.dirname(QASM_RESOURCES_DIR), "custom_gate_complex.qasm") - ref_module = load(ref_file_path) - check_unrolled_qasm(dumps(module), dumps(ref_module)) - - -def test_correct_include_processing_complex(): - """Test that complex custom include statements are processed correctly.""" - file_path = os.path.join(QASM_RESOURCES_DIR, "include_vars.qasm") - module = load(file_path) - ref_file_path = os.path.join(QASM_RESOURCES_DIR, "include_vars_ref.qasm") - ref_module = load(ref_file_path) - check_unrolled_qasm(dumps(module), dumps(ref_module)) - - -def test_include_custom_subroutine(): - """Test that inclusion of custom subroutines is processed correctly.""" - file_path = os.path.join(QASM_RESOURCES_DIR, "include_sub.qasm") +@pytest.mark.parametrize( + "test_file, ref_file", + [ + ("include_custom_gates.qasm", "../custom_gate_complex.qasm"), + ("include_vars.qasm", "include_vars_ref.qasm"), + ("include_sub.qasm", "include_sub_ref.qasm"), + ("multi_include.qasm", "multi_include_ref.qasm"), + ("include_sandwiched.qasm", "include_sandwiched_ref.qasm"), + ("include_nested.qasm", "include_nested_ref.qasm"), + ], +) +def test_valid_include_processing(test_file, ref_file): + """Test that valid include statements are processed correctly.""" + file_path = os.path.join(QASM_RESOURCES_DIR, test_file) module = load(file_path) - ref_file_path = os.path.join(QASM_RESOURCES_DIR, "include_sub_ref.qasm") + ref_file_path = os.path.join(QASM_RESOURCES_DIR, ref_file) ref_module = load(ref_file_path) check_unrolled_qasm(dumps(module), dumps(ref_module)) @@ -73,30 +66,3 @@ def test_circular_import(): match="Circular include detected for file 'circular_import.qasm' at line 3, column 10", ): load(file_path) - - -def test_multiple_includes(): - """Test that multiple include statements in a file are processed correctly.""" - file_path = os.path.join(QASM_RESOURCES_DIR, "multi_include.qasm") - module = load(file_path) - ref_file_path = os.path.join(QASM_RESOURCES_DIR, "multi_include_ref.qasm") - ref_module = load(ref_file_path) - check_unrolled_qasm(dumps(module), dumps(ref_module)) - - -def test_include_sandwiched(): - """Test that include statements sandwiched between other statements are processed correctly.""" - file_path = os.path.join(QASM_RESOURCES_DIR, "include_sandwiched.qasm") - module = load(file_path) - ref_file_path = os.path.join(QASM_RESOURCES_DIR, "include_sandwiched_ref.qasm") - ref_module = load(ref_file_path) - check_unrolled_qasm(dumps(module), dumps(ref_module)) - - -def test_nested_include_processing(): - """Test that nested include statements are recursively processed correctly.""" - file_path = os.path.join(QASM_RESOURCES_DIR, "include_nested.qasm") - module = load(file_path) - ref_file_path = os.path.join(QASM_RESOURCES_DIR, "include_nested_ref.qasm") - ref_module = load(ref_file_path) - check_unrolled_qasm(dumps(module), dumps(ref_module)) From 1ff19643974537fc9f6b36ab4434627e37d7437c Mon Sep 17 00:00:00 2001 From: LukeAndreesen <107073823+LukeAndreesen@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:01:20 -0500 Subject: [PATCH 12/12] testing for qasm2 and backward compatibility --- .../resources/qasm/custom_include/custom_qasm2.qasm | 7 +++++++ .../resources/qasm/custom_include/include_qasm2.qasm | 9 +++++++++ .../qasm/custom_include/include_qasm2_backward.qasm | 7 +++++++ .../custom_include/include_qasm2_backward_ref.qasm | 12 ++++++++++++ .../qasm/custom_include/include_qasm2_ref.qasm | 12 ++++++++++++ tests/qasm3/test_preprocess.py | 10 ++++++++++ 6 files changed, 57 insertions(+) create mode 100644 tests/qasm3/resources/qasm/custom_include/custom_qasm2.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/include_qasm2.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/include_qasm2_backward.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/include_qasm2_backward_ref.qasm create mode 100644 tests/qasm3/resources/qasm/custom_include/include_qasm2_ref.qasm diff --git a/tests/qasm3/resources/qasm/custom_include/custom_qasm2.qasm b/tests/qasm3/resources/qasm/custom_include/custom_qasm2.qasm new file mode 100644 index 00000000..fdc72f75 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/custom_qasm2.qasm @@ -0,0 +1,7 @@ +OPENQASM 2.0; +include "qelib1.inc"; + + +def my_func(int[32] a) -> int[32] { + return a; +} \ No newline at end of file diff --git a/tests/qasm3/resources/qasm/custom_include/include_qasm2.qasm b/tests/qasm3/resources/qasm/custom_include/include_qasm2.qasm new file mode 100644 index 00000000..0f1fab4e --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_qasm2.qasm @@ -0,0 +1,9 @@ +OPENQASM 2.0; +include "qelib1.inc"; +include "custom_qasm2.qasm"; + +qreg q[2]; +creg c[2]; + +int [32] var = 5; +int [32] result = my_func(var); \ No newline at end of file diff --git a/tests/qasm3/resources/qasm/custom_include/include_qasm2_backward.qasm b/tests/qasm3/resources/qasm/custom_include/include_qasm2_backward.qasm new file mode 100644 index 00000000..0df5ab1a --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_qasm2_backward.qasm @@ -0,0 +1,7 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include "custom_qasm2.qasm"; + +qubit[2] q; +int [32] var = 5; +int [32] result = my_func(var); \ No newline at end of file diff --git a/tests/qasm3/resources/qasm/custom_include/include_qasm2_backward_ref.qasm b/tests/qasm3/resources/qasm/custom_include/include_qasm2_backward_ref.qasm new file mode 100644 index 00000000..d2583f74 --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_qasm2_backward_ref.qasm @@ -0,0 +1,12 @@ +OPENQASM 3.0; +include "stdgates.inc"; +include "qelib1.inc"; + + +def my_func(int[32] a) -> int[32] { + return a; +} + +qubit[2] q; +int [32] var = 5; +int [32] result = my_func(var); \ No newline at end of file diff --git a/tests/qasm3/resources/qasm/custom_include/include_qasm2_ref.qasm b/tests/qasm3/resources/qasm/custom_include/include_qasm2_ref.qasm new file mode 100644 index 00000000..293b2ede --- /dev/null +++ b/tests/qasm3/resources/qasm/custom_include/include_qasm2_ref.qasm @@ -0,0 +1,12 @@ +OPENQASM 2.0; +include "qelib1.inc"; + +def my_func(int[32] a) -> int[32] { + return a; +} + +qreg q[2]; +creg c[2]; + +int [32] var = 5; +int [32] result = my_func(var); \ No newline at end of file diff --git a/tests/qasm3/test_preprocess.py b/tests/qasm3/test_preprocess.py index c3b1b515..da5b85ac 100644 --- a/tests/qasm3/test_preprocess.py +++ b/tests/qasm3/test_preprocess.py @@ -32,12 +32,22 @@ @pytest.mark.parametrize( "test_file, ref_file", [ + # Include basic custom gate ("include_custom_gates.qasm", "../custom_gate_complex.qasm"), + # Include variable definitions ("include_vars.qasm", "include_vars_ref.qasm"), + # Include subroutines ("include_sub.qasm", "include_sub_ref.qasm"), + # Multiple includes in single file ("multi_include.qasm", "multi_include_ref.qasm"), + # Include 'sandwiched' between other code ("include_sandwiched.qasm", "include_sandwiched_ref.qasm"), + # Recursive inclusions (include files within included files) ("include_nested.qasm", "include_nested_ref.qasm"), + # Include QASM2 file in QASM2 file + ("include_qasm2.qasm", "include_qasm2_ref.qasm"), + # Backward compatibility: Include QASM2 file in QASM3 file + ("include_qasm2_backward.qasm", "include_qasm2_backward_ref.qasm"), ], ) def test_valid_include_processing(test_file, ref_file):