diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index 454c05985..a9c481b02 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -35,9 +35,10 @@ from .runtime_job import RuntimeJob from .runtime_job_v2 import RuntimeJobV2 from .ibm_backend import IBMBackend +from .utils import validate_isa_circuits, validate_no_dd_with_dynamic_circuits from .utils.default_session import get_cm_session from .utils.deprecation import issue_deprecation_msg, deprecate_function -from .utils.utils import validate_isa_circuits, is_simulator, validate_no_dd_with_dynamic_circuits +from .utils.utils import is_simulator from .constants import DEFAULT_DECODERS from .qiskit_runtime_service import QiskitRuntimeService from .fake_provider.local_service import QiskitRuntimeLocalService diff --git a/qiskit_ibm_runtime/base_runtime_job.py b/qiskit_ibm_runtime/base_runtime_job.py index 60dcfaf57..9b24b6f84 100644 --- a/qiskit_ibm_runtime/base_runtime_job.py +++ b/qiskit_ibm_runtime/base_runtime_job.py @@ -30,8 +30,7 @@ from qiskit_ibm_runtime import qiskit_runtime_service -from .utils import utc_to_local -from .utils.utils import validate_job_tags +from .utils import utc_to_local, validate_job_tags from .utils.queueinfo import QueueInfo from .utils.deprecation import issue_deprecation_msg from .constants import DEFAULT_DECODERS, API_TO_JOB_ERROR_MESSAGE diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index 50e15b562..1bbb7b94c 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -35,7 +35,7 @@ from .utils.deprecation import deprecate_arguments, issue_deprecation_msg from .utils.qctrl import validate as qctrl_validate from .utils.qctrl import validate_v2 as qctrl_validate_v2 - +from .utils import validate_estimator_pubs # pylint: disable=unused-import,cyclic-import from .session import Session @@ -184,6 +184,7 @@ def run( """ coerced_pubs = [EstimatorPub.coerce(pub, precision) for pub in pubs] + validate_estimator_pubs(coerced_pubs) return self._run(coerced_pubs) # type: ignore[arg-type] def _validate_options(self, options: dict) -> None: diff --git a/qiskit_ibm_runtime/ibm_backend.py b/qiskit_ibm_runtime/ibm_backend.py index 858d937d3..7117c20a3 100644 --- a/qiskit_ibm_runtime/ibm_backend.py +++ b/qiskit_ibm_runtime/ibm_backend.py @@ -45,7 +45,6 @@ Session as ProviderSession, ) -from .utils.utils import validate_job_tags from . import qiskit_runtime_service # pylint: disable=unused-import,cyclic-import from .runtime_job import RuntimeJob @@ -60,7 +59,7 @@ from .utils.deprecation import issue_deprecation_msg from .utils.options import QASM2Options, QASM3Options from .api.exceptions import RequestsApiError -from .utils import local_to_utc, are_circuits_dynamic +from .utils import local_to_utc, are_circuits_dynamic, validate_job_tags from .utils.pubsub import Publisher diff --git a/qiskit_ibm_runtime/qiskit_runtime_service.py b/qiskit_ibm_runtime/qiskit_runtime_service.py index 76f91219f..d3e483f74 100644 --- a/qiskit_ibm_runtime/qiskit_runtime_service.py +++ b/qiskit_ibm_runtime/qiskit_runtime_service.py @@ -30,8 +30,6 @@ from .utils.hgp import to_instance_format, from_instance_format from .utils.backend_decoder import configuration_from_server_data - -from .utils.utils import validate_job_tags from .accounts import AccountManager, Account, ChannelType from .api.clients import AuthClient, VersionClient from .api.clients.runtime import RuntimeClient @@ -43,7 +41,7 @@ from .utils.result_decoder import ResultDecoder from .runtime_job import RuntimeJob from .runtime_job_v2 import RuntimeJobV2 -from .utils import RuntimeDecoder, RuntimeEncoder +from .utils import RuntimeDecoder, RuntimeEncoder, validate_job_tags from .api.client_parameters import ClientParameters from .runtime_options import RuntimeOptions from .ibm_backend import IBMBackend diff --git a/qiskit_ibm_runtime/runtime_options.py b/qiskit_ibm_runtime/runtime_options.py index f58697e4d..485348427 100644 --- a/qiskit_ibm_runtime/runtime_options.py +++ b/qiskit_ibm_runtime/runtime_options.py @@ -22,7 +22,7 @@ from qiskit.providers.backend import Backend from .exceptions import IBMInputValueError -from .utils.utils import validate_job_tags +from .utils import validate_job_tags @dataclass(init=False) diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index ea204aa94..243093a28 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -16,7 +16,6 @@ import os from typing import Dict, Optional, Sequence, Any, Union, Iterable import logging -import warnings from qiskit.circuit import QuantumCircuit from qiskit.primitives import BaseSampler @@ -36,6 +35,7 @@ from .utils.deprecation import deprecate_arguments, issue_deprecation_msg from .utils.qctrl import validate as qctrl_validate from .utils.qctrl import validate_v2 as qctrl_validate_v2 +from .utils import validate_classical_registers from .options import SamplerOptions logger = logging.getLogger(__name__) @@ -146,12 +146,7 @@ def run(self, pubs: Iterable[SamplerPubLike], *, shots: int | None = None) -> Ru """ coerced_pubs = [SamplerPub.coerce(pub, shots) for pub in pubs] - if any(len(pub.circuit.cregs) == 0 for pub in coerced_pubs): - warnings.warn( - "One of your circuits has no output classical registers and so the result " - "will be empty. Did you mean to add measurement instructions?", - UserWarning, - ) + validate_classical_registers(coerced_pubs) return self._run(coerced_pubs) # type: ignore[arg-type] diff --git a/qiskit_ibm_runtime/utils/__init__.py b/qiskit_ibm_runtime/utils/__init__.py index d3e5c4000..2b2eb4b30 100644 --- a/qiskit_ibm_runtime/utils/__init__.py +++ b/qiskit_ibm_runtime/utils/__init__.py @@ -24,7 +24,14 @@ get_runtime_api_base_url, resolve_crn, are_circuits_dynamic, +) +from .validations import ( + validate_estimator_pubs, + validate_classical_registers, validate_no_dd_with_dynamic_circuits, + validate_isa_circuits, + validate_job_tags, ) + from .json import RuntimeEncoder, RuntimeDecoder, to_base64_string from . import pubsub diff --git a/qiskit_ibm_runtime/utils/utils.py b/qiskit_ibm_runtime/utils/utils.py index d083c3f4b..168f6c7f2 100644 --- a/qiskit_ibm_runtime/utils/utils.py +++ b/qiskit_ibm_runtime/utils/utils.py @@ -20,7 +20,7 @@ import re from queue import Queue from threading import Condition -from typing import List, Optional, Any, Dict, Union, Tuple, Sequence +from typing import List, Optional, Any, Dict, Union, Tuple from urllib.parse import urlparse import requests @@ -31,7 +31,6 @@ from qiskit.circuit import QuantumCircuit, ControlFlowOp from qiskit.transpiler import Target from qiskit.providers.backend import BackendV1, BackendV2 -from qiskit_ibm_runtime.exceptions import IBMInputValueError def is_simulator(backend: BackendV1 | BackendV2) -> bool: @@ -79,26 +78,6 @@ def is_isa_circuit(circuit: QuantumCircuit, target: Target) -> str: return "" -def validate_isa_circuits(circuits: Sequence[QuantumCircuit], target: Target) -> None: - """Validate if all circuits are ISA circuits - - Args: - circuits: A list of QuantumCircuits. - target: The backend target - """ - for circuit in circuits: - message = is_isa_circuit(circuit, target) - if message: - raise IBMInputValueError( - message - + " Circuits that do not match the target hardware definition are no longer " - "supported after March 4, 2024. See the transpilation documentation " - "(https://docs.quantum.ibm.com/transpile) for instructions to transform circuits and " - "the primitive examples (https://docs.quantum.ibm.com/run/primitives-examples) to see " - "this coupled with operator transformations." - ) - - def are_circuits_dynamic(circuits: List[QuantumCircuit], qasm_default: bool = True) -> bool: """Checks if the input circuits are dynamic.""" for circuit in circuits: @@ -113,37 +92,6 @@ def are_circuits_dynamic(circuits: List[QuantumCircuit], qasm_default: bool = Tr return False -def validate_no_dd_with_dynamic_circuits(circuits: List[QuantumCircuit], options: Any) -> None: - """Validate that if dynamical decoupling options are enabled, - no circuit in the pubs is dynamic - - Args: - circuits: A list of QuantumCircuits. - options: The runtime options - """ - if not hasattr(options, "dynamical_decoupling") or not options.dynamical_decoupling.enable: - return - if are_circuits_dynamic(circuits, False): - raise IBMInputValueError( - "Dynamical decoupling currently cannot be used with dynamic circuits" - ) - - -def validate_job_tags(job_tags: Optional[List[str]]) -> None: - """Validates input job tags. - - Args: - job_tags: Job tags to be validated. - - Raises: - IBMInputValueError: If the job tags are invalid. - """ - if job_tags and ( - not isinstance(job_tags, list) or not all(isinstance(tag, str) for tag in job_tags) - ): - raise IBMInputValueError("job_tags needs to be a list of strings.") - - def get_iam_api_url(cloud_url: str) -> str: """Computes the IAM API URL for the given IBM Cloud URL.""" parsed_url = urlparse(cloud_url) diff --git a/qiskit_ibm_runtime/utils/validations.py b/qiskit_ibm_runtime/utils/validations.py new file mode 100644 index 000000000..382dcbb32 --- /dev/null +++ b/qiskit_ibm_runtime/utils/validations.py @@ -0,0 +1,127 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2024. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Utilities for data validation.""" +from typing import List, Sequence, Optional, Any +import warnings +import keyword +from qiskit import QuantumCircuit +from qiskit.transpiler import Target +from qiskit.primitives.containers.sampler_pub import SamplerPub +from qiskit.primitives.containers.estimator_pub import EstimatorPub +from qiskit_ibm_runtime.utils.utils import is_isa_circuit, are_circuits_dynamic +from qiskit_ibm_runtime.exceptions import IBMInputValueError + + +def validate_classical_registers(pubs: List[SamplerPub]) -> None: + """Validates the classical registers in the pub won't cause problems that can be caught client-side. + + Args: + pubs: The list of pubs to validate + + Raises: + ValueError: If any circuit has a size-0 creg. + ValueError: If any circuit has a creg whose name is not a valid identifier. + ValueError: If any circuit has a creg whose name is a Python keyword. + """ + + for index, pub in enumerate(pubs): + if len(pub.circuit.cregs) == 0: + warnings.warn( + f"The {index}-th circuit has no output classical registers so the result " + "will be empty. Did you mean to add measurement instructions?", + UserWarning, + ) + + for reg in pub.circuit.cregs: + # size 0 classical register will crash the server-side sampler + if reg.size == 0: + raise ValueError( + f"Classical register {reg.name} is of size 0, which is not allowed" + ) + if not reg.name.isidentifier(): + raise ValueError( + f"Classical register names must be valid identifiers, but {reg.name} " + f"is not. Valid identifiers contain only alphanumeric letters " + f"(a-z and A-Z), decimal digits (0-9), or underscores (_)" + ) + if keyword.iskeyword(reg.name): + raise ValueError( + f"Classical register names cannot be Python keywords, but {reg.name} " + f"is such a keyword. You can see the Python keyword list here: " + f"https://docs.python.org/3/reference/lexical_analysis.html#keywords" + ) + + +def validate_estimator_pubs(pubs: List[EstimatorPub]) -> None: + """Validates the estimator pubs won't cause problems that can be caught client-side. + + Args: + pubs: The list of pubs to validate + + Raises: + ValueError: If any observable array is of size 0 + """ + for pub in pubs: + if pub.observables.shape == (0,): + raise ValueError("Empty observables array is not allowed") + + +def validate_isa_circuits(circuits: Sequence[QuantumCircuit], target: Target) -> None: + """Validate if all circuits are ISA circuits + + Args: + circuits: A list of QuantumCircuits. + target: The backend target + """ + for circuit in circuits: + message = is_isa_circuit(circuit, target) + if message: + raise IBMInputValueError( + message + + " Circuits that do not match the target hardware definition are no longer " + "supported after March 4, 2024. See the transpilation documentation " + "(https://docs.quantum.ibm.com/transpile) for instructions to transform circuits and " + "the primitive examples (https://docs.quantum.ibm.com/run/primitives-examples) to see " + "this coupled with operator transformations." + ) + + +def validate_no_dd_with_dynamic_circuits(circuits: List[QuantumCircuit], options: Any) -> None: + """Validate that if dynamical decoupling options are enabled, + no circuit in the pubs is dynamic + + Args: + circuits: A list of QuantumCircuits. + options: The runtime options + """ + if not hasattr(options, "dynamical_decoupling") or not options.dynamical_decoupling.enable: + return + if are_circuits_dynamic(circuits, False): + raise IBMInputValueError( + "Dynamical decoupling currently cannot be used with dynamic circuits" + ) + + +def validate_job_tags(job_tags: Optional[List[str]]) -> None: + """Validates input job tags. + + Args: + job_tags: Job tags to be validated. + + Raises: + IBMInputValueError: If the job tags are invalid. + """ + if job_tags and ( + not isinstance(job_tags, list) or not all(isinstance(tag, str) for tag in job_tags) + ): + raise IBMInputValueError("job_tags needs to be a list of strings.") diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py index c0a72c85b..f36e4b732 100644 --- a/test/unit/test_estimator.py +++ b/test/unit/test_estimator.py @@ -277,3 +277,12 @@ def test_unsupported_dynamical_decoupling_with_dynamic_circuits(self): "Dynamical decoupling currently cannot be used with dynamic circuits", ): inst.run(in_pubs) + + def test_estimator_validations(self): + """Test exceptions when failing client-side validations.""" + backend = get_mocked_backend() + inst = EstimatorV2(backend=backend) + circ = QuantumCircuit(2) + obs = [] + with self.assertRaisesRegex(ValueError, "Empty observables array is not allowed"): + inst.run(pubs=[(circ, obs)]) diff --git a/test/unit/test_sampler.py b/test/unit/test_sampler.py index 53ad8fc0b..6a1916efe 100644 --- a/test/unit/test_sampler.py +++ b/test/unit/test_sampler.py @@ -17,7 +17,7 @@ from ddt import data, ddt, named_data import numpy as np -from qiskit import QuantumCircuit, transpile +from qiskit import QuantumCircuit, transpile, QuantumRegister, ClassicalRegister from qiskit.primitives.containers.sampler_pub import SamplerPub from qiskit.circuit.library import RealAmplitudes from qiskit_ibm_runtime import Sampler, Session, SamplerV2, SamplerOptions, IBMInputValueError @@ -152,6 +152,31 @@ def test_run_default_options(self): f"{inputs} and {expected} not partially equal.", ) + def test_sampler_validations(self): + """Test exceptions when failing client-side validations.""" + with Session( + service=FakeRuntimeService(channel="ibm_quantum", token="abc"), + backend="common_backend", + ) as session: + inst = SamplerV2(session=session) + circ = QuantumCircuit(QuantumRegister(2), ClassicalRegister(0)) + with self.assertRaisesRegex(ValueError, "Classical register .* is of size 0"): + inst.run([(circ,)]) + + creg = ClassicalRegister(2, "not-an-identifier") + circ = QuantumCircuit(QuantumRegister(2), creg) + with self.assertRaisesRegex( + ValueError, "Classical register names must be valid identifiers" + ): + inst.run([(circ,)]) + + creg = ClassicalRegister(2, "lambda") + circ = QuantumCircuit(QuantumRegister(2), creg) + with self.assertRaisesRegex( + ValueError, "Classical register names cannot be Python keywords" + ): + inst.run([(circ,)]) + def test_run_dynamic_circuit_with_fractional_opted(self): """Fractional opted backend cannot run dynamic circuits.""" model_backend = FakeFractionalBackend()