diff --git a/qiskit/providers/ibmq/api/clients/runtime.py b/qiskit/providers/ibmq/api/clients/runtime.py index 4caf5b77b..9f9eeed66 100644 --- a/qiskit/providers/ibmq/api/clients/runtime.py +++ b/qiskit/providers/ibmq/api/clients/runtime.py @@ -13,7 +13,7 @@ """Client for accessing IBM Quantum runtime service.""" import logging -from typing import Any, List, Dict, Union, Optional +from typing import Any, Dict, Optional from qiskit.providers.ibmq.credentials import Credentials from qiskit.providers.ibmq.api.session import RetrySession @@ -111,7 +111,8 @@ def program_run( credentials: Credentials, backend_name: str, params: Dict, - image: str + image: str, + log_level: Optional[str] = None ) -> Dict: """Run the specified program. @@ -121,6 +122,7 @@ def program_run( backend_name: Name of the backend to run the program. params: Parameters to use. image: The runtime image to use. + log_level: Log level to use. Returns: JSON response. @@ -128,7 +130,7 @@ def program_run( return self.api.program_run(program_id=program_id, hub=credentials.hub, group=credentials.group, project=credentials.project, backend_name=backend_name, params=params, - image=image) + image=image, log_level=log_level) def program_delete(self, program_id: str) -> None: """Delete the specified program. diff --git a/qiskit/providers/ibmq/api/rest/runtime.py b/qiskit/providers/ibmq/api/rest/runtime.py index a55343bd2..1c0321f9d 100644 --- a/qiskit/providers/ibmq/api/rest/runtime.py +++ b/qiskit/providers/ibmq/api/rest/runtime.py @@ -13,7 +13,7 @@ """Runtime REST adapter.""" import logging -from typing import Dict, List, Any, Union, Optional +from typing import Dict, Any, Union, Optional import json from concurrent import futures @@ -114,7 +114,8 @@ def program_run( project: str, backend_name: str, params: Dict, - image: str + image: str, + log_level: Optional[str] = None ) -> Dict: """Execute the program. @@ -126,6 +127,7 @@ def program_run( backend_name: Name of the backend. params: Program parameters. image: Runtime image. + log_level: Log level to use. Returns: JSON response. @@ -140,6 +142,8 @@ def program_run( 'params': params, 'runtime': image } + if log_level: + payload["log_level"] = log_level data = json.dumps(payload, cls=RuntimeEncoder) return self.session.post(url, data=data).json() diff --git a/qiskit/providers/ibmq/runtime/__init__.py b/qiskit/providers/ibmq/runtime/__init__.py index 9c789088f..3854c290c 100644 --- a/qiskit/providers/ibmq/runtime/__init__.py +++ b/qiskit/providers/ibmq/runtime/__init__.py @@ -220,11 +220,13 @@ def interim_result_callback(job_id, interim_result): RuntimeEncoder RuntimeDecoder ParameterNamespace + RuntimeOptions """ from .ibm_runtime_service import IBMRuntimeService from .runtime_job import RuntimeJob from .runtime_program import RuntimeProgram, ParameterNamespace +from .runtime_options import RuntimeOptions from .program.user_messenger import UserMessenger from .program.program_backend import ProgramBackend from .program.result_decoder import ResultDecoder diff --git a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py index 7ccf9fccd..78188aade 100644 --- a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py +++ b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py @@ -15,7 +15,6 @@ import logging from typing import Dict, Callable, Optional, Union, List, Any, Type import json -import re import warnings from qiskit.providers.exceptions import QiskitBackendNotFoundError @@ -27,6 +26,7 @@ from .exceptions import (QiskitRuntimeError, RuntimeDuplicateProgramError, RuntimeProgramNotFound, RuntimeJobNotFound) from .program.result_decoder import ResultDecoder +from .runtime_options import RuntimeOptions from ..api.clients.runtime import RuntimeClient from ..api.exceptions import RequestsApiError @@ -232,7 +232,7 @@ def _to_program(self, response: Dict) -> RuntimeProgram: def run( self, program_id: str, - options: Dict, + options: Union[RuntimeOptions, Dict], inputs: Union[Dict, ParameterNamespace], callback: Optional[Callable] = None, result_decoder: Optional[Type[ResultDecoder]] = None, @@ -242,8 +242,9 @@ def run( Args: program_id: Program ID. - options: Runtime options that control the execution environment. - Currently the only available option is ``backend_name``, which is required. + options: Runtime options that control the execution environment. See + :class:`RuntimeOptions` for all available options. + Currently the only required option is ``backend_name``. inputs: Program input parameters. These input values are passed to the runtime program. callback: Callback function to be invoked for any interim results. @@ -263,25 +264,31 @@ def run( Raises: IBMQInputValueError: If input is invalid. """ - if 'backend_name' not in options: - raise IBMQInputValueError('"backend_name" is required field in "options"') + if isinstance(options, dict): + options = RuntimeOptions(**options) + + if image: + warnings.warn("Passing the 'image' keyword to IBMRuntimeService.run is " + "deprecated and will be removed in a future release. " + "Please pass it in as part of 'options'.", + DeprecationWarning, stacklevel=2) + options.image = image + + options.validate() + # If using params object, extract as dictionary if isinstance(inputs, ParameterNamespace): inputs.validate() inputs = vars(inputs) - if image and not \ - re.match("[a-zA-Z0-9]+([/.\\-_][a-zA-Z0-9]+)*:[a-zA-Z0-9]+([.\\-_][a-zA-Z0-9]+)*$", - image): - raise IBMQInputValueError('"image" needs to be in form of image_name:tag') - - backend_name = options['backend_name'] + backend_name = options.backend_name result_decoder = result_decoder or ResultDecoder response = self._api_client.program_run(program_id=program_id, credentials=self._provider.credentials, backend_name=backend_name, params=inputs, - image=image) + image=options.image, + log_level=options.log_level) backend = self._provider.get_backend(backend_name) job = RuntimeJob(backend=backend, @@ -290,7 +297,7 @@ def run( job_id=response['id'], program_id=program_id, params=inputs, user_callback=callback, result_decoder=result_decoder, - image=image) + image=options.image) return job def upload_program( diff --git a/qiskit/providers/ibmq/runtime/runtime_options.py b/qiskit/providers/ibmq/runtime/runtime_options.py new file mode 100644 index 000000000..131fa2c53 --- /dev/null +++ b/qiskit/providers/ibmq/runtime/runtime_options.py @@ -0,0 +1,64 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. + +"""Runtime options that control the execution environment.""" + +import re +import logging +from dataclasses import dataclass +from typing import Optional + +from ..exceptions import IBMQInputValueError + + +@dataclass +class RuntimeOptions: + """Class for representing runtime execution options. + + Args: + backend_name: target backend to run on. This is required. + image: the runtime image used to execute the program, specified in + the form of ``image_name:tag``. Not all accounts are + authorized to select a different image. + log_level: logging level to set in the execution environment. The valid + log levels are: ``DEBUG``, ``INFO``, ``WARNING``, ``ERROR``, and ``CRITICAL``. + The default level is ``WARNING``. + """ + + backend_name: str = None + image: Optional[str] = None + log_level: Optional[str] = None + + def validate(self) -> None: + """Validate options. + + Raises: + IBMQInputValueError: If one or more option is invalid. + """ + if self.image and not re.match( + "[a-zA-Z0-9]+([/.\\-_][a-zA-Z0-9]+)*:[a-zA-Z0-9]+([.\\-_][a-zA-Z0-9]+)*$", + self.image, + ): + raise IBMQInputValueError('"image" needs to be in form of image_name:tag') + + if not self.backend_name: + raise IBMQInputValueError( + '"backend_name" is required field in "options".' + ) + + if self.log_level and not isinstance( + logging.getLevelName(self.log_level.upper()), int + ): + raise IBMQInputValueError( + f"{self.log_level} is not a valid log level. The valid log levels are: `DEBUG`, " + f"`INFO`, `WARNING`, `ERROR`, and `CRITICAL`." + ) diff --git a/releasenotes/notes/logging-level-c64f05bdb36c0685.yaml b/releasenotes/notes/logging-level-c64f05bdb36c0685.yaml new file mode 100644 index 000000000..ba1e71a29 --- /dev/null +++ b/releasenotes/notes/logging-level-c64f05bdb36c0685.yaml @@ -0,0 +1,12 @@ +--- +features: + - | + You can now specify a different logging level in the ``options`` keyword + when submitting a Qiskit Runtime job with the + :meth:`qiskit.providers.ibmq.runtime.IBMRuntimeService.run` method. +deprecations: + - | + The ``image`` keyword in the + :meth:`qiskit.providers.ibmq.runtime.IBMRuntimeService.run` method is + deprecated. You should instead specify the image to use in the ``options`` + keyword. diff --git a/requirements.txt b/requirements.txt index 02e5c2277..d1a6a7133 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ python-dateutil>=2.8.0 websocket-client>=1.0.1 websockets>=10.0; python_version >= '3.7' websockets>=9.1; python_version < '3.7' +dataclasses>=0.8; python_version < '3.7' diff --git a/setup.py b/setup.py index 3b65a1e1b..acd1faa20 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,8 @@ "python-dateutil>=2.8.0", "websocket-client>=1.0.1", "websockets>=10.0 ; python_version>='3.7'", - "websockets>=9.1 ; python_version<'3.7'" + "websockets>=9.1 ; python_version<'3.7'", + "dataclasses>=0.8 ; python_version<'3.7'" ] # Handle version. diff --git a/test/ibmq/runtime/fake_runtime_client.py b/test/ibmq/runtime/fake_runtime_client.py index e333b842e..33cd95b6d 100644 --- a/test/ibmq/runtime/fake_runtime_client.py +++ b/test/ibmq/runtime/fake_runtime_client.py @@ -78,7 +78,7 @@ class BaseFakeRuntimeJob: _executor = ThreadPoolExecutor() # pylint: disable=bad-option-value,consider-using-with def __init__(self, job_id, program_id, hub, group, project, backend_name, final_status, - params, image): + params, image, log_level=None): """Initialize a fake job.""" self._job_id = job_id self._status = final_status or "QUEUED" @@ -89,6 +89,7 @@ def __init__(self, job_id, program_id, hub, group, project, backend_name, final_ self._backend_name = backend_name self._params = params self._image = image + self.log_level = log_level if final_status is None: self._future = self._executor.submit(self._auto_progress) self._result = None @@ -292,7 +293,8 @@ def program_run( credentials: Credentials, backend_name: str, params: str, - image: Optional[str] = "" + image: Optional[str] = "", + log_level: Optional[str] = "WARNING" ): """Run the specified program.""" job_id = uuid.uuid4().hex @@ -301,6 +303,7 @@ def program_run( hub=credentials.hub, group=credentials.group, project=credentials.project, backend_name=backend_name, params=params, final_status=self._final_status, image=image, + log_level=log_level, **self._job_kwargs) self._jobs[job_id] = job return {'id': job_id} diff --git a/test/ibmq/runtime/test_runtime.py b/test/ibmq/runtime/test_runtime.py index e6d5198aa..2521fc4b4 100644 --- a/test/ibmq/runtime/test_runtime.py +++ b/test/ibmq/runtime/test_runtime.py @@ -441,6 +441,12 @@ def test_run_program_with_custom_runtime_image(self): self.assertTrue(job.result()) self.assertEqual(job.image, image) + def test_run_program_with_custom_log_level(self): + """Test running program with a custom log level.""" + job = self._run_program(log_level="DEBUG") + job_raw = self.runtime._api_client._get_job(job.job_id()) + self.assertEqual(job_raw.log_level, "DEBUG") + def test_retrieve_program_data(self): """Test retrieving program data""" program_id = self._upload_program(name="qiskit-test") @@ -739,9 +745,11 @@ def _upload_program(self, name=None, max_execution_time=300, return program_id def _run_program(self, program_id=None, inputs=None, job_classes=None, final_status=None, - decoder=None, image=""): + decoder=None, image="", log_level=None): """Run a program.""" options = {'backend_name': "some_backend"} + if log_level: + options["log_level"] = log_level if final_status is not None: self.runtime._api_client.set_final_status(final_status) elif job_classes: diff --git a/test/ibmq/runtime/test_runtime_integration.py b/test/ibmq/runtime/test_runtime_integration.py index 847af3c56..86a2ad80d 100644 --- a/test/ibmq/runtime/test_runtime_integration.py +++ b/test/ibmq/runtime/test_runtime_integration.py @@ -45,10 +45,13 @@ class TestRuntimeIntegration(IBMQTestCase): import random import time import warnings +import logging from qiskit import transpile from qiskit.circuit.random import random_circuit +logger = logging.getLogger("qiskit-test") + def prepare_circuits(backend): circuit = random_circuit(num_qubits=5, depth=4, measure=True, seed=random.randint(0, 1000)) @@ -68,6 +71,7 @@ def main(backend, user_messenger, **kwargs): user_messenger.publish(final_result, final=True) print("this is a stdout message") warnings.warn("this is a stderr message") + logger.info("this is an info log") """ RUNTIME_PROGRAM_METADATA = { @@ -667,6 +671,20 @@ def test_job_logs(self): self.assertIn("this is a stdout message", job_logs) self.assertIn("this is a stderr message", job_logs) + def test_run_program_log_level(self): + """Test running with a custom log level.""" + levels = ["INFO", "ERROR"] + for level in levels: + with self.subTest(level=level): + job = self._run_program(log_level=level) + job.wait_for_final_state() + expect_info_msg = level == "INFO" + self.assertEqual( + "info log" in job.logs(), + expect_info_msg, + f"Job log is {job.logs()}", + ) + def _validate_program(self, program): """Validate a program.""" self.assertTrue(program) @@ -710,13 +728,15 @@ def _assert_complex_types_equal(self, expected, received): def _run_program(self, program_id=None, iterations=1, interim_results=None, final_result=None, - callback=None): + callback=None, log_level=None): """Run a program.""" inputs = {'iterations': iterations, 'interim_results': interim_results or {}, 'final_result': final_result or {}} pid = program_id or self.program_id options = {'backend_name': self.backend.name()} + if log_level: + options["log_level"] = log_level job = self.provider.runtime.run(program_id=pid, inputs=inputs, options=options, callback=callback) self.log.info("Runtime job %s submitted.", job.job_id())