-
Notifications
You must be signed in to change notification settings - Fork 27
fix: use presence tests for loads() kwargs and reject invalid ones #361
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
274b13c
a064938
81b764d
93c6c23
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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) | ||
|
|
||
|
|
@@ -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) | ||
| if isinstance(program, str): | ||
| try: | ||
| program = openqasm3.parse(program) | ||
|
|
@@ -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: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Type: Implementation Rationale: For the two bool kwargs this is a silent behaviour reversal, not a no-op. On Verified end to end on a A caller building kwargs from a config mapping ( Treating Change Requested: Keep the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed on all three counts — keeping (a) The PR description's auto-generated section was flatly wrong and is gone. (b) The changelog entry now spells out that 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 | ||
|
|
||
|
|
||
|
|
||
| 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) |
There was a problem hiding this comment.
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**kwargsstraight through (entrypoint.py:82), so both new exceptions surface fromload()as well — but its docstring documents neither**kwargsnor aRaisessection, whileloads()gained both. The messages also hardcode the wrong name:load("f.qasm", devise_qubits=5)reportsloads() got unexpected keyword argument(s): devise_qubits, pointing the caller at a function they did not call.Change Requested: Add a
Raisessection toload()coveringTypeErrorandValueErrorand cross-referenceloads()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.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 81b764d.
_validate_kwargstakes afuncname, andload()calls it directly beforeprocess_include_statementsso the message names the entrypoint the caller invoked.load()also gains theRaisessection and a**kwargspointer toloads().Pinned with
test_load_errors_name_load_not_loads, covering both theTypeErrorand theValueError.