diff --git a/CHANGELOG.md b/CHANGELOG.md index 736421bc..a2eb0c68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Types of changes: ## Unreleased ### Added +- Added an `include_dir` kwarg to `loads()` and `load()`, naming the directory custom `include` statements resolve against. A program given as a string could not resolve includes at all, and failed later naming the gate rather than the include. Resolution is opt-in: without the kwarg, no files are read. ([#368](https://github.com/qBraid/pyqasm/issues/368)) ### Improved / Modified diff --git a/src/pyqasm/entrypoint.py b/src/pyqasm/entrypoint.py index 810fbc68..49565f0c 100644 --- a/src/pyqasm/entrypoint.py +++ b/src/pyqasm/entrypoint.py @@ -27,7 +27,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 +from pyqasm.preprocess import process_include_sources, process_include_statements if TYPE_CHECKING: import openqasm3.ast @@ -43,6 +43,9 @@ "play_in_cal_block": "_play_in_cal", } +# kwargs consumed by the entrypoint itself rather than stored on the module +_PREPROCESS_KWARGS = ("include_dir",) + # kwargs that must be positive when given; an explicit None counts as not given _POSITIVE_KWARGS = ( "device_qubits", @@ -65,9 +68,14 @@ def _validate_kwargs(kwargs: dict, func: str = "loads") -> None: a real number. ValueError: If a positive-only kwarg is zero or negative. """ - unknown = sorted(set(kwargs) - set(_LOADS_KWARG_ATTRS)) + unknown = sorted(set(kwargs) - set(_LOADS_KWARG_ATTRS) - set(_PREPROCESS_KWARGS)) if unknown: raise TypeError(f"{func}() got unexpected keyword argument(s): {', '.join(unknown)}") + include_dir = kwargs.get("include_dir") + if include_dir is not None and not isinstance(include_dir, str): + raise TypeError( + f"{func}() kwarg 'include_dir' must be a path, got {type(include_dir).__name__}" + ) for name in _POSITIVE_KWARGS: value = kwargs.get(name) if value is None: @@ -86,11 +94,13 @@ def load(filename: str, **kwargs) -> QasmModule: filename (str): The filename of the OpenQASM program to validate. **kwargs: Forwarded to :func:`loads`; see it for the supported names. + ``include_dir`` is consumed here, and is tried before the directory of the + including file. Raises: TypeError: If ``filename`` is not a string, or if an unrecognized keyword argument is passed. - FileNotFoundError: If the file does not exist. + FileNotFoundError: If the file does not exist, or an included file is not found. ValueError: If a numeric keyword argument is zero or negative. ValidationError: If the program fails parsing or semantic validation. @@ -104,7 +114,8 @@ def load(filename: str, **kwargs) -> QasmModule: raise FileNotFoundError(f"QASM file '{filename}' not found.") # validate here as well so the message names load(), the function the caller invoked _validate_kwargs(kwargs, func="load") - program = process_include_statements(filename) + # consumed here, so loads() does not walk the already-inlined program again + program = process_include_statements(filename, kwargs.pop("include_dir", None)) return loads(program, **kwargs) @@ -130,6 +141,12 @@ def loads(program: openqasm3.ast.Program | str, **kwargs) -> QasmModule: - **play_in_cal_block** (bool): Whether to allow play in defcal. + - **include_dir** (str): Directory holding the program's custom include files. + A program given as a string has no filesystem location of its own, so this + is the only way to resolve its includes. Omit it and custom includes are + left unresolved and passed through, as before; pass it and an include the + directory does not hold raises a ``ValidationError`` naming it. + Passing an explicit ``None`` for any of these means "not passed": the module default is kept. Pass ``False`` to turn off a boolean kwarg. @@ -137,21 +154,32 @@ def loads(program: openqasm3.ast.Program | str, **kwargs) -> QasmModule: TypeError: If the input is not a string or an `openqasm3.ast.Program` instance, if an unrecognized keyword argument is passed, or if a numeric keyword argument is not a real number. - ValueError: If a numeric keyword argument is zero or negative. - ValidationError: If the program fails parsing or semantic validation. + ValueError: If a numeric keyword argument is zero or negative, or if + ``include_dir`` is passed with an already-parsed `openqasm3.ast.Program`. + ValidationError: If the program fails parsing or semantic validation, or if a + custom include is not found in ``include_dir``. Returns: QasmModule: An object containing the parsed qasm representation along with some useful metadata and methods """ _validate_kwargs(kwargs) + include_dir = kwargs.pop("include_dir", None) if isinstance(program, str): + if include_dir is not None: + program = process_include_sources(program, include_dir) try: program = openqasm3.parse(program) except openqasm3.parser.QASM3ParsingError as err: raise ValidationError(f"Failed to parse OpenQASM string: {err}") from err elif not isinstance(program, openqasm3.ast.Program): raise TypeError("Input quantum program must be of type 'str' or 'openqasm3.ast.Program'.") + elif include_dir is not None: + # a parsed Program has no include statements left to resolve + raise ValueError( + "loads() kwarg 'include_dir' needs the program as a string; an " + "'openqasm3.ast.Program' has already been parsed." + ) if program.version not in SUPPORTED_QASM_VERSIONS: raise ValidationError( f"Unsupported OpenQASM version: {program.version}. " diff --git a/src/pyqasm/preprocess.py b/src/pyqasm/preprocess.py index c02cfdb8..b903b059 100644 --- a/src/pyqasm/preprocess.py +++ b/src/pyqasm/preprocess.py @@ -31,6 +31,8 @@ class IncludeContext: include_stdgates: bool = False include_qelib1: bool = False visited: set[str] = field(default_factory=set) + # directory to resolve includes against, tried before the including file's own + include_dir: str | None = None PATTERNS = { @@ -47,13 +49,15 @@ class IncludeContext: } -def process_include_statements(filename: str) -> str: +def process_include_statements(filename: str, include_dir: str | None = None) -> 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. + include_dir (str | None): Directory to resolve includes against, tried before the + directory of the including file. Returns: str: The fully include-resolved program content. @@ -62,40 +66,73 @@ def process_include_statements(filename: str) -> str: FileNotFoundError: If an included file cannot be found. ValidationError: If a circular include is detected. """ - # Generate context for include processing - ctx = IncludeContext() - with open(filename, "r", encoding="utf-8") as f: program = f.read() + return _inline_includes(program, filename, include_dir) + + +def process_include_sources(program: str, include_dir: str) -> str: + """ + Resolve the include statements of a program held as a string, against a directory the + caller names. + + A string has no filesystem location of its own to resolve relative includes against, + so ``include_dir`` supplies one (issue #368). + + Args: + program (str): The OpenQASM program text. + include_dir (str): Directory holding the include files. + + Returns: + str: The fully include-resolved program content. + + Raises: + ValidationError: If an include is not found in the directory, or is circular. + """ + return _inline_includes(program, None, include_dir) + + +def _inline_includes(program: str, origin: str | None, include_dir: str | None) -> str: + """ + Inline the include statements of a program, from either a file or a string. + + Args: + program (str): The OpenQASM program text. + origin (str | None): The path the text was read from, or None for a string. + include_dir (str | None): Directory to resolve includes against. + + Returns: + str: The fully include-resolved program content. + """ + ctx = IncludeContext(include_dir=include_dir) _collect_headers(ctx, program) # 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 - # Recursively process and replace includes in-line - result = _process_file(ctx, filename) + if origin is not None: + ctx.visited.add(os.path.basename(origin)) # Mark as visited to avoid looping + + # bind first: the walk appends to base_file_header when it meets a std include + # inside an included file + result = _process_source(ctx, program, origin) - # Return processed file with original header + # Return processed program with original header return "\n".join(ctx.base_file_header) + "\n\n" + result def _process_file(ctx: IncludeContext, filepath: str) -> str: """ - Process a single file, replacing include statements with the contents of the included files - recursively. + Read a file and inline its own include statements 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. + str: The fully include-resolved file content, empty if already included. """ filename = os.path.basename(filepath) if filename in ctx.visited: @@ -105,6 +142,27 @@ def _process_file(ctx: IncludeContext, filepath: str) -> str: program = f.read() ctx.visited.add(filename) # Mark as visited to avoid looping + return _process_source(ctx, program, filepath) + + +def _process_source(ctx: IncludeContext, program: str, origin: str | None) -> str: + """ + Replace the include statements in one source with the contents of the included files. + + Args: + ctx (IncludeContext): The context for processing includes. + program (str): The text of the source to process. + origin (str | None): The path the text was read from, or None for a string. + + Returns: + str: The fully include-resolved program content. + + Raises: + FileNotFoundError: If an included file cannot be found. + ValidationError: If a circular include is detected, or an include cannot be + resolved for a program given as a string. + """ + filename = os.path.basename(origin) if origin is not None else None new_program_lines = [] for idx, line in enumerate(program.splitlines()): @@ -113,19 +171,22 @@ def _process_file(ctx: IncludeContext, filepath: str) -> str: if match: include_filename = match.group(1) # Check for circular imports - if include_filename.strip() == filename.strip(): + if filename is not None and 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) + include_path = _resolve_include_path(origin, include_filename, ctx.include_dir) 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}" - ) + where = f"at line {idx + 1}, column {line.find(include_filename) + 1}" + if origin is None: # a string can only have come from include_dir + raise ValidationError( + f"Include file '{include_filename}' not found in include_dir " + f"'{ctx.include_dir}' {where}: '{line.strip()}'" + ) + raise FileNotFoundError(f"Include file '{include_filename}' not found {where}") # Recursively process include statements within the included file included_content = _process_file(ctx, include_path) new_program_lines.append(included_content) @@ -137,7 +198,7 @@ def _process_file(ctx: IncludeContext, filepath: str) -> str: ): new_program_lines.append(line) - # Join and save cleaned content for this file + # Join and save cleaned content for this source cleaned = "\n".join(new_program_lines) return cleaned # return the fully inlined program @@ -162,18 +223,27 @@ def _check_for_std_includes(ctx: IncludeContext, line: str) -> None: ctx.base_file_header.append('include "qelib1.inc";') -def _resolve_include_path(base_file: str, file_to_include: str) -> str | None: +def _resolve_include_path( + base_file: str | None, file_to_include: str, include_dir: str | None = None +) -> str | None: """ Resolve the include path for a given file. Args: - base_file (str): The base file from which the include is being made. + base_file (str | None): The file the include is made from, or None for a string. file_to_include (str): The file to include. + include_dir (str | None): Directory to try before the base file's own. Returns: 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] + possible_paths = [] + if include_dir is not None: + possible_paths.append(os.path.join(include_dir, file_to_include)) + if base_file is not None: + # a string has no directory of its own, and must not fall back to the cwd + possible_paths += [os.path.join(os.path.dirname(base_file), file_to_include)] + possible_paths += [file_to_include] for path in possible_paths: if os.path.isfile(path): return path diff --git a/tests/test_include.py b/tests/test_include.py index c1d508ad..67de9f26 100644 --- a/tests/test_include.py +++ b/tests/test_include.py @@ -17,9 +17,10 @@ """ +import openqasm3 import pytest -from pyqasm import ValidationError, dumps, loads +from pyqasm import ValidationError, dumps, load, loads from tests.utils import check_unrolled_qasm @@ -108,3 +109,111 @@ def test_remove_includes_without_include(): module = module.remove_includes(in_place=False) module.unroll() check_unrolled_qasm(dumps(module), expected_qasm_str) + + +# --- include_dir: resolving includes for a program held as a string (issue #368) --- + +MYGATES_INC = "gate mygate q {\n h q;\n}\n" + +PROGRAM_WITH_CUSTOM_INCLUDE = """ +OPENQASM 2.0; +include "qelib1.inc"; +include "mygates.inc"; +qreg q[2]; +mygate q[0]; +""" + + +@pytest.fixture(name="include_dir") +def include_dir_fixture(tmp_path): + """A directory holding mygates.inc, which defines `mygate` as an `h`.""" + (tmp_path / "mygates.inc").write_text(MYGATES_INC, encoding="utf-8") + return str(tmp_path) + + +def test_loads_resolves_include_from_include_dir(include_dir): + """A string has no directory of its own, so the caller names one. Without this the + gate call failed with 'Unsupported / undeclared QASM operation: mygate'.""" + expected_qasm_str = """ + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + h q[0]; + """ + module = loads(PROGRAM_WITH_CUSTOM_INCLUDE, include_dir=include_dir) + module.unroll() + check_unrolled_qasm(dumps(module), expected_qasm_str) + + +def test_loads_and_load_agree_on_the_same_program(tmp_path, include_dir): + """The two entrypoints are documented as equivalent, so one program must give the + same result whether it arrives as a file or as a string.""" + path = tmp_path / "prog.qasm" + path.write_text(PROGRAM_WITH_CUSTOM_INCLUDE, encoding="utf-8") + + from_file = load(str(path)) + from_string = loads(PROGRAM_WITH_CUSTOM_INCLUDE, include_dir=include_dir) + from_file.unroll() + from_string.unroll() + assert dumps(from_file) == dumps(from_string) + + +def test_nested_include_resolves_beside_the_file_that_named_it(tmp_path): + """A resolved include has a path of its own, so its own includes resolve beside it.""" + (tmp_path / "outer.inc").write_text( + 'include "inner.inc";\ngate outer q { inner q; }\n', encoding="utf-8" + ) + (tmp_path / "inner.inc").write_text("gate inner q {\n x q;\n}\n", encoding="utf-8") + qasm_str = """ + OPENQASM 2.0; + include "qelib1.inc"; + include "outer.inc"; + qreg q[1]; + outer q[0]; + """ + module = loads(qasm_str, include_dir=str(tmp_path)) + module.unroll() + assert "x q[0];" in dumps(module) + + +def test_loads_reports_the_unresolved_include_by_name(tmp_path): + """The reported symptom was a downstream 'undeclared operation' error that sent you + looking at the gate table. The error now names the include and the directory.""" + with pytest.raises(ValidationError, match="'mygates.inc' not found in include_dir"): + loads(PROGRAM_WITH_CUSTOM_INCLUDE, include_dir=str(tmp_path)) + + +def test_loads_without_include_dir_still_passes_includes_through(): + """Resolution is opt-in: without the kwarg loads() reads no files at all, and an + unresolved custom include reaches the output exactly as before.""" + module = loads(PROGRAM_WITH_CUSTOM_INCLUDE) + assert 'include "mygates.inc";' in dumps(module) + + +def test_include_dir_wins_over_the_directory_of_the_file(tmp_path, include_dir): + """For load(), include_dir is tried first, so a caller can override an include that + sits next to the program.""" + (tmp_path / "beside").mkdir() + (tmp_path / "beside" / "mygates.inc").write_text("gate mygate q { x q; }\n", encoding="utf-8") + path = tmp_path / "beside" / "prog.qasm" + path.write_text(PROGRAM_WITH_CUSTOM_INCLUDE, encoding="utf-8") + + module = load(str(path), include_dir=include_dir) + module.unroll() + assert "h q[0];" in dumps(module) + assert "x q[0];" not in dumps(module) + + +def test_include_dir_rejected_for_a_parsed_program(include_dir): + """An already-parsed Program has no include statements left to resolve, so the kwarg + cannot do anything and must not be silently ignored.""" + program = openqasm3.parse("OPENQASM 3.0;\nqubit[1] q;\n") + with pytest.raises(ValueError, match="include_dir"): + loads(program, include_dir=include_dir) + + +@pytest.mark.parametrize("value", [3, ["dir"], {"a": "b"}]) +def test_include_dir_rejects_a_non_path(value): + """The kwarg must fail at the call site, in the shape the other loads() kwargs use.""" + with pytest.raises(TypeError, match="include_dir"): + loads(PROGRAM_WITH_CUSTOM_INCLUDE, include_dir=value)