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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ Types of changes:
- Consolidated the hardcoded `"__PYQASM_QUBITS__"` string literals scattered across `visitor.py`, `transformer.py` and `pulse/utils.py` into a single `INTERNAL_QUBIT_REGISTER` constant in `elements.py`, alongside an `is_internal_qubit_register()` helper that is now the one place the internal register is recognised. ([#325](https://github.com/qBraid/pyqasm/pull/325))
- Added / updated type hinting for `base.py`, `qasm2.py`, `qasm3.py`, and `visitor.py` signatures, plus the `visit_statement` / `visit_basic_block` signatures in `pulse/visitor.py`. Fixed grammatical typos in `base.py`, `qasm2.py`, `qasm3.py`, and `visitor.py` docstrings. Fixes incorrect return types in the docstrings of `visitor.py`. ([#346](https://github.com/qBraid/pyqasm/pull/346))
- Added / updated type hinting for `pulse/visitor.py` signatures and fixes incorrect return types in the docstrings of `pulse/visitor.py`. ([#365](https://github.com/qBraid/pyqasm/pull/365))
- **Breaking:** `loads()` and `load()` now raise `TypeError` on an unrecognised keyword argument instead of silently ignoring it, so a misspelling like `loads(src, devise_qubits=5)` fails at the call site rather than being dropped. Any caller currently passing a stray or misspelled kwarg will break on upgrade. ([#356](https://github.com/qBraid/pyqasm/issues/356))

### Deprecated

### Removed

### Fixed
- Fixed `loads()` silently discarding a falsy kwarg value. The seven documented kwargs were stored behind a walrus truthiness test, so a caller value of `0`, `0.0`, `False`, `{}` or `[]` was treated as though the kwarg had never been passed. Each kwarg is now tested for presence, with an explicit `None` still meaning "not passed". What that changes per kwarg: `extern_functions={}` / `[]` is now stored rather than dropped; a non-positive `device_qubits` / `device_cycle_time` / `compiler_angle_type_size` / `frame_limit_per_port` is now **rejected** with a `ValueError` at the call site rather than silently dropped, along with a non-numeric or `bool` value (`TypeError`); and `frame_in_def_cal=None` / `play_in_cal_block=None` now mean "not passed" — previously an explicit `None` was stored and read as falsy, so passing `None` disabled the check that the `True` default enables. Pass `False` for the old `None` behaviour. ([#356](https://github.com/qBraid/pyqasm/issues/356))
- Fixed `unroll(consolidate_qubits=True)` emitting two unrelated address spaces for a program mixing declared registers with physical qubits — a consolidated register plus as-written `$n` references. Such programs now raise a `ValidationError` naming the physical qubits, and a program using only physical qubits no longer receives an internal register declaration nothing references. ([#353](https://github.com/qBraid/pyqasm/issues/353))
- Fixed external and verbatim-box gates counting the depth of the decomposition they skipped: `unroll(external_gates=["crz"])` on a single `crz` reported `depth() == 12` while emitting one statement. An external gate now records its own depth, matching how a single-level external custom gate is already handled. ([#352](https://github.com/qBraid/pyqasm/issues/352))
- Fixed `has_measurements()` / `remove_measurements()` and `has_barriers()` / `remove_barriers()` missing occurrences inside `for` / `while` / `switch` bodies on a module that has not been unrolled — e.g. a measurement inside a `for` loop was invisible and `remove_measurements()` was a no-op. The statement walker now descends into loop and switch bodies. ([#354](https://github.com/qBraid/pyqasm/issues/354))
Expand Down
89 changes: 73 additions & 16 deletions src/pyqasm/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,68 @@
if TYPE_CHECKING:
import openqasm3.ast

# maps each documented loads() kwarg to the module attribute that stores it
_LOADS_KWARG_ATTRS = {
"device_qubits": "_device_qubits",
"device_cycle_time": "_device_cycle_time",
"compiler_angle_type_size": "_compiler_angle_type_size",
"extern_functions": "_extern_functions",
"frame_in_def_cal": "_frame_in_def_cal",
"frame_limit_per_port": "_frame_limit_per_port",
"play_in_cal_block": "_play_in_cal",
}

# kwargs that must be positive when given; an explicit None counts as not given
_POSITIVE_KWARGS = (
"device_qubits",
"device_cycle_time",
"compiler_angle_type_size",
"frame_limit_per_port",
)


def _validate_kwargs(kwargs: dict, func: str = "loads") -> None:
"""Reject unknown kwarg names and unusable values at the call site, instead of
silently dropping them (issue #356).

Args:
kwargs (dict): The keyword arguments the caller passed.
func (str): The entrypoint to name in error messages.

Raises:
TypeError: If a kwarg name is unrecognised, or a positive-only kwarg is not
a real number.
ValueError: If a positive-only kwarg is zero or negative.
"""
unknown = sorted(set(kwargs) - set(_LOADS_KWARG_ATTRS))
if unknown:
raise TypeError(f"{func}() got unexpected keyword argument(s): {', '.join(unknown)}")
for name in _POSITIVE_KWARGS:
value = kwargs.get(name)
if value is None:
continue
# bool is a subclass of int, so True would otherwise pass as a count of 1
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{func}() kwarg '{name}' must be a number, got {type(value).__name__}")
if value <= 0:
raise ValueError(f"{func}() kwarg '{name}' must be positive, got {value!r}")


def load(filename: str, **kwargs) -> QasmModule:
"""Loads an OpenQASM program into a `QasmModule` object.

Args:
filename (str): The filename of the OpenQASM program to validate.

**kwargs: Forwarded to :func:`loads`; see it for the supported names.

Raises:
TypeError: If ``filename`` is not a string, or if an unrecognized keyword
argument is passed.
FileNotFoundError: If the file does not exist.
ValueError: If a numeric keyword argument is zero or negative.
ValidationError: If the program fails parsing or semantic validation.

Returns:
QasmModule: An object containing the parsed qasm representation along with
some useful metadata and methods
Expand All @@ -47,6 +102,8 @@ def load(filename: str, **kwargs) -> QasmModule:
raise TypeError("Input 'filename' must be of type 'str'.")
if not os.path.isfile(filename):
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)
return loads(program, **kwargs)

Expand All @@ -73,14 +130,21 @@ def loads(program: openqasm3.ast.Program | str, **kwargs) -> QasmModule:

- **play_in_cal_block** (bool): Whether to allow play in defcal.

Passing an explicit ``None`` for any of these means "not passed": the
module default is kept. Pass ``False`` to turn off a boolean kwarg.

Raises:
TypeError: If the input is not a string or an `openqasm3.ast.Program` instance.
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.

Returns:
QasmModule: An object containing the parsed qasm representation along with
some useful metadata and methods
"""
_validate_kwargs(kwargs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type: Implementation
Severity: Low

Rationale: load() forwards **kwargs straight through (entrypoint.py:82), so both new exceptions surface from load() as well — but its docstring documents neither **kwargs nor a Raises section, while loads() gained both. The messages also hardcode the wrong name: load("f.qasm", devise_qubits=5) reports loads() got unexpected keyword argument(s): devise_qubits, pointing the caller at a function they did not call.

Change Requested: Add a Raises section to load() covering TypeError and ValueError and cross-reference loads() for the supported **kwargs. Optionally pass the caller's name into _validate_kwargs (_validate_kwargs(kwargs, func="load")) so the message names the entrypoint that was actually invoked.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 81b764d. _validate_kwargs takes a func name, and load() calls it directly before process_include_statements so the message names the entrypoint the caller invoked. load() also gains the Raises section and a **kwargs pointer to loads().

Pinned with test_load_errors_name_load_not_loads, covering both the TypeError and the ValueError.

if isinstance(program, str):
try:
program = openqasm3.parse(program)
Expand All @@ -99,21 +163,14 @@ def loads(program: openqasm3.ast.Program | str, **kwargs) -> QasmModule:

qasm_module = Qasm3Module if program.version.startswith("3") else Qasm2Module
module = qasm_module("main", program)
# Store device_qubits on the module for later use
if dev_qbts := kwargs.get("device_qubits"):
module._device_qubits = dev_qbts
if dev_cycle_time := kwargs.get("device_cycle_time"):
module._device_cycle_time = dev_cycle_time
if compiler_angle_type_size := kwargs.get("compiler_angle_type_size"):
module._compiler_angle_type_size = compiler_angle_type_size
if extern_functions := kwargs.get("extern_functions"):
module._extern_functions = extern_functions
if "frame_in_def_cal" in kwargs:
module._frame_in_def_cal = kwargs["frame_in_def_cal"]
if frame_limit_per_port := kwargs.get("frame_limit_per_port"):
module._frame_limit_per_port = frame_limit_per_port
if "play_in_cal_block" in kwargs:
module._play_in_cal = kwargs["play_in_cal_block"]
# `is not None`, not truthiness: a falsy value is a caller value, not an omission.
# An explicit None means "not passed", so defaults like extern_functions={} and
# frame_in_def_cal=True are never clobbered.
for name, attr in _LOADS_KWARG_ATTRS.items():
if kwargs.get(name) is not None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type: Implementation
Severity: Medium

Rationale: For the two bool kwargs this is a silent behaviour reversal, not a no-op. On main the storage test was if "frame_in_def_cal" in kwargs, so an explicit None was stored. Under is not None it is discarded and the default True survives (modules/base.py:150, :152). Both attributes are consumed as falsy guards (pulse/visitor.py:714, :728), so the effective flag flips from off to on.

Verified end to end on a defcal containing newframe:

                                    main            this PR
frame_in_def_cal omitted            unroll OK       unroll OK
frame_in_def_cal=None               ValidationError unroll OK   <-- flipped
frame_in_def_cal=False              ValidationError ValidationError

A caller building kwargs from a config mapping (cfg.get("frame_in_def_cal")) silently gets the opposite policy, with no error and no warning. Nothing is lost in capability — False reaches the same state None used to — but the change is undocumented, and the PR description's auto-generated section asserts the opposite ("Stores explicitly provided None values for supported loads() kwargs"). tests/test_entrypoint.py:55 locks in the new behaviour without noting that it differs from main.

Treating None as "not passed" is a defensible and arguably better contract; the problem is that it ships undeclared.

Change Requested: Keep the is not None semantics, and (a) correct the stale claim in the PR description, (b) add the frame_in_def_cal / play_in_cal_block None change to the CHANGELOG entry, and (c) note in the loads() docstring that an explicit None means "not passed" for every kwarg — the docstring currently gives no way for a reader to predict this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on all three counts — keeping is not None and declaring it properly, in 81b764d.

(a) The PR description's auto-generated section was flatly wrong and is gone. (b) The changelog entry now spells out that frame_in_def_cal=None / play_in_cal_block=None mean "not passed", that this differs from main where an explicit None was stored and read as falsy, and that False reaches the old state. (c) The loads() docstring now says an explicit None means "not passed" for every kwarg and to pass False to turn off a boolean.

Your framing is right: nothing is lost in capability, the problem was that it shipped undeclared.

# setattr would happily create a phantom attribute if the module renamed one
assert hasattr(module, attr), f"module has no attribute '{attr}' for kwarg '{name}'"
setattr(module, attr, kwargs[name])
return module


Expand Down
123 changes: 123 additions & 0 deletions tests/test_entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# 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 the loads() kwargs (issue #356).

"""

import pytest

from pyqasm.entrypoint import load, loads

QASM = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
h q[0];
"""


@pytest.mark.parametrize(
"kwarg, attr, value",
[
("device_qubits", "_device_qubits", 5),
("device_cycle_time", "_device_cycle_time", 1e-9),
("compiler_angle_type_size", "_compiler_angle_type_size", 32),
("extern_functions", "_extern_functions", {"f": (["int"], "int")}),
("frame_in_def_cal", "_frame_in_def_cal", False),
("frame_limit_per_port", "_frame_limit_per_port", 2),
("play_in_cal_block", "_play_in_cal", False),
],
)
def test_loads_kwargs_are_stored(kwarg, attr, value):
"""Every documented kwarg must be stored on the module, falsy values included."""
module = loads(QASM, **{kwarg: value})
assert getattr(module, attr) == value


def test_loads_kwarg_none_means_not_passed():
"""An explicit None leaves the attribute at its default."""
assert loads(QASM, device_qubits=None)._device_qubits is None
# defaults that are not None must survive an explicit None
extern_functions = loads(QASM, extern_functions=None)._extern_functions
assert isinstance(extern_functions, dict) and not extern_functions
assert loads(QASM, frame_in_def_cal=None)._frame_in_def_cal is True


def test_loads_empty_extern_functions_is_stored():
"""A falsy dict is a caller value, not an omission."""
extern_functions = loads(QASM, extern_functions={})._extern_functions
assert isinstance(extern_functions, dict) and not extern_functions


@pytest.mark.parametrize(
"kwarg, value",
[
("device_qubits", 0),
("device_qubits", -5),
("device_cycle_time", 0.0),
("compiler_angle_type_size", 0),
("frame_limit_per_port", -1),
],
)
def test_loads_rejects_non_positive_values(kwarg, value):
"""Zero or negative values are rejected at the call site instead of surfacing
later as a confusing validation message (issue #356)."""
with pytest.raises(ValueError, match=kwarg):
loads(QASM, **{kwarg: value})


@pytest.mark.parametrize(
"kwarg, value",
[
("device_qubits", "5"),
("device_qubits", []),
("device_qubits", complex(1)),
("device_cycle_time", {}),
("frame_limit_per_port", "2"),
],
)
def test_loads_rejects_non_numeric_values(kwarg, value):
"""A non-numeric value must name the kwarg rather than surfacing as a bare
comparison error from inside the validator (issue #356)."""
with pytest.raises(TypeError, match=kwarg):
loads(QASM, **{kwarg: value})


@pytest.mark.parametrize("kwarg", ["device_qubits", "compiler_angle_type_size"])
@pytest.mark.parametrize("value", [True, False])
def test_loads_rejects_bool_for_numeric_kwargs(kwarg, value):
"""bool is a subclass of int, so True would otherwise pass positivity and be
stored as a count of 1, while False would report 'must be positive'."""
with pytest.raises(TypeError, match=kwarg):
loads(QASM, **{kwarg: value})


def test_loads_rejects_unknown_kwargs():
"""A typo in a kwarg name must fail where it is made, not silently do nothing."""
with pytest.raises(TypeError, match="devise_qubits"):
loads(QASM, devise_qubits=5)


def test_load_errors_name_load_not_loads(tmp_path):
"""load() forwards **kwargs, so its errors must name the function the caller
actually invoked."""
path = tmp_path / "prog.qasm"
path.write_text(QASM, encoding="utf-8")

with pytest.raises(TypeError, match=r"load\(\) got unexpected keyword argument"):
load(str(path), devise_qubits=5)
with pytest.raises(ValueError, match=r"load\(\) kwarg 'device_qubits'"):
load(str(path), device_qubits=0)
Loading