From b1ae216d230037a00410e47bd8cd3d38b02ecfdb Mon Sep 17 00:00:00 2001 From: Rathish Cholarajan Date: Wed, 27 Oct 2021 17:29:45 -0400 Subject: [PATCH 1/8] Runtime release Q4 (#1069) * Remove version field from runtime program (#152) * Remove version field from runtime program * Add release note * Rename isPublic to is_public when creating or reading runtime programs (#155) * Update programId to program_id when running program (#139) This needs to change in the program upload body request in order to meet the IBM Cloud API guidance. Co-authored-by: Jessie Yu * Add support to view program update date * Upload runtime program using 'data' field (#157) * Read programs from "programs" array in response (#161) * Pass program as base64 string to update (#168) * Accept JSON schema as program metadata (#158) * Accept JSON schema as program metadata * Update qiskit_ibm/runtime/ibm_runtime_service.py Co-authored-by: Jessie Yu * Apply suggestions from code review * Apply suggestions from code review Co-authored-by: Jessie Yu Co-authored-by: Jessie Yu * Pass program params as object (#171) * Fix integration tests Co-authored-by: Renier Morales Co-authored-by: Jessie Yu --- qiskit/providers/ibmq/api/clients/runtime.py | 28 ++-- qiskit/providers/ibmq/api/rest/runtime.py | 59 +++---- qiskit/providers/ibmq/runtime/__init__.py | 7 +- .../ibmq/runtime/ibm_runtime_service.py | 146 +++++++---------- .../program/program_metadata_sample.json | 46 ++++-- .../providers/ibmq/runtime/runtime_program.py | 151 ++++++++---------- qiskit/providers/ibmq/runtime/utils.py | 12 ++ ...-program-update-date-7325797d7abd36ad.yaml | 5 + ...remove-version-field-0543061d4a7e059a.yaml | 4 + ...metadata-json-schema-46f034ada7443cf9.yaml | 10 ++ test/ibmq/runtime/fake_runtime_client.py | 35 ++-- test/ibmq/runtime/test_runtime.py | 95 ++++++----- test/ibmq/runtime/test_runtime_integration.py | 25 ++- 13 files changed, 311 insertions(+), 312 deletions(-) create mode 100644 releasenotes/notes/feature-program-update-date-7325797d7abd36ad.yaml create mode 100644 releasenotes/notes/remove-version-field-0543061d4a7e059a.yaml create mode 100644 releasenotes/notes/upgrade-metadata-json-schema-46f034ada7443cf9.yaml diff --git a/qiskit/providers/ibmq/api/clients/runtime.py b/qiskit/providers/ibmq/api/clients/runtime.py index 90c5346ef..452d86773 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 List, Dict, Union, Optional +from typing import Any, List, Dict, Union, Optional from qiskit.providers.ibmq.credentials import Credentials from qiskit.providers.ibmq.api.session import RetrySession @@ -39,7 +39,7 @@ def __init__( **credentials.connection_parameters()) self.api = Runtime(self._session) - def list_programs(self) -> List[Dict]: + def list_programs(self) -> Dict[str, Any]: """Return a list of runtime programs. Returns: @@ -49,30 +49,22 @@ def list_programs(self) -> List[Dict]: def program_create( self, - program_data: bytes, + program_data: str, name: str, description: str, max_execution_time: int, is_public: Optional[bool] = False, - version: Optional[str] = None, - backend_requirements: Optional[Dict] = None, - parameters: Optional[Dict] = None, - return_values: Optional[List] = None, - interim_results: Optional[List] = None + spec: Optional[Dict] = None ) -> Dict: """Create a new program. Args: name: Name of the program. - program_data: Program data. + program_data: Program data (base64 encoded). description: Program description. max_execution_time: Maximum execution time. is_public: Whether the program should be public. - version: Program version. - backend_requirements: Backend requirements. - parameters: Program parameters. - return_values: Program return values. - interim_results: Program interim results. + spec: Backend requirements, parameters, interim results, return values, etc. Returns: Server response. @@ -81,9 +73,7 @@ def program_create( program_data=program_data, name=name, description=description, max_execution_time=max_execution_time, - is_public=is_public, version=version, backend_requirements=backend_requirements, - parameters=parameters, return_values=return_values, - interim_results=interim_results + is_public=is_public, spec=spec ) def program_get(self, program_id: str) -> Dict: @@ -127,7 +117,7 @@ def program_run( program_id: str, credentials: Credentials, backend_name: str, - params: str, + params: Dict, image: str ) -> Dict: """Run the specified program. @@ -160,7 +150,7 @@ def program_update(self, program_id: str, program_data: str) -> None: Args: program_id: Program ID. - program_data: Program data. + program_data: Program data (base64 encoded). """ self.api.program(program_id).update(program_data) diff --git a/qiskit/providers/ibmq/api/rest/runtime.py b/qiskit/providers/ibmq/api/rest/runtime.py index 81f250b74..382d9c5af 100644 --- a/qiskit/providers/ibmq/api/rest/runtime.py +++ b/qiskit/providers/ibmq/api/rest/runtime.py @@ -19,6 +19,7 @@ from .base import RestAdapterBase from ..session import RetrySession +from ...runtime.utils import RuntimeEncoder logger = logging.getLogger(__name__) @@ -54,7 +55,7 @@ def program_job(self, job_id: str) -> 'ProgramJob': """ return ProgramJob(self.session, job_id) - def list_programs(self) -> List[Dict]: + def list_programs(self) -> Dict[str, Any]: """Return a list of runtime programs. Returns: @@ -65,54 +66,36 @@ def list_programs(self) -> List[Dict]: def create_program( self, - program_data: bytes, + program_data: str, name: str, description: str, max_execution_time: int, is_public: Optional[bool] = False, - version: Optional[str] = None, - backend_requirements: Optional[Dict] = None, - parameters: Optional[Dict] = None, - return_values: Optional[List] = None, - interim_results: Optional[List] = None + spec: Optional[Dict] = None ) -> Dict: """Upload a new program. Args: - program_data: Program data. + program_data: Program data (base64 encoded). name: Name of the program. description: Program description. max_execution_time: Maximum execution time. is_public: Whether the program should be public. - version: Program version. - backend_requirements: Backend requirements. - parameters: Program parameters. - return_values: Program return values. - interim_results: Program interim results. + spec: Backend requirements, parameters, interim results, return values, etc. Returns: JSON response. """ url = self.get_url('programs') - data = {'name': name, - 'cost': str(max_execution_time), - 'description': description.encode(), - 'max_execution_time': max_execution_time, - 'isPublic': is_public} - if version is not None: - data['version'] = version - if backend_requirements: - data['backendRequirements'] = json.dumps(backend_requirements) - if parameters: - data['parameters'] = json.dumps({"doc": parameters}) - if return_values: - data['returnValues'] = json.dumps(return_values) - if interim_results: - data['interimResults'] = json.dumps(interim_results) - - files = {'program': (name, program_data)} # type: ignore[dict-item] - response = self.session.post(url, data=data, files=files).json() - return response + payload = {'name': name, + 'data': program_data, + 'cost': max_execution_time, + 'description': description, + 'is_public': is_public} + if spec is not None: + payload['spec'] = spec + data = json.dumps(payload) + return self.session.post(url, data=data).json() def program_run( self, @@ -121,7 +104,7 @@ def program_run( group: str, project: str, backend_name: str, - params: str, + params: Dict, image: str ) -> Dict: """Execute the program. @@ -140,15 +123,15 @@ def program_run( """ url = self.get_url('jobs') payload = { - 'programId': program_id, + 'program_id': program_id, 'hub': hub, 'group': group, 'project': project, 'backend': backend_name, - 'params': [params], + 'params': params, 'runtime': image } - data = json.dumps(payload) + data = json.dumps(payload, cls=RuntimeEncoder) return self.session.post(url, data=data).json() def jobs_get(self, limit: int = None, skip: int = None, pending: bool = None) -> Dict: @@ -243,11 +226,11 @@ def update(self, program_data: str) -> None: """Update a program. Args: - program_data: Program data. + program_data: Program data (base64 encoded). """ url = self.get_url("data") self.session.put(url, data=program_data, - headers={'Content-Type': 'text/plain'}) + headers={'Content-Type': 'application/octet-stream'}) class ProgramJob(RestAdapterBase): diff --git a/qiskit/providers/ibmq/runtime/__init__.py b/qiskit/providers/ibmq/runtime/__init__.py index e6fdf74d8..9c789088f 100644 --- a/qiskit/providers/ibmq/runtime/__init__.py +++ b/qiskit/providers/ibmq/runtime/__init__.py @@ -193,14 +193,11 @@ def interim_result_callback(job_id, interim_result): provider = IBMQ.load_account() program_id = provider.runtime.upload_program( data="my_vqe.py", - metadata="my_vqe_metadata.json", - version="1.2" + metadata="my_vqe_metadata.json" ) In the example above, the file ``my_vqe.py`` contains the program data, and -``my_vqe_metadata.json`` contains the program metadata. An additional -parameter ``version`` is also specified, which takes precedence over any -``version`` value specified in ``my_vqe_metadata.json``. +``my_vqe_metadata.json`` contains the program metadata. Method :meth:`IBMRuntimeService.delete_program` allows you to delete a program. diff --git a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py index b73989190..b6d0a4531 100644 --- a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py +++ b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py @@ -15,15 +15,14 @@ import logging from typing import Dict, Callable, Optional, Union, List, Any, Type import json -import copy import re from qiskit.providers.exceptions import QiskitBackendNotFoundError from qiskit.providers.ibmq import accountprovider # pylint: disable=unused-import from .runtime_job import RuntimeJob -from .runtime_program import RuntimeProgram, ProgramParameter, ProgramResult, ParameterNamespace -from .utils import RuntimeEncoder, RuntimeDecoder +from .runtime_program import RuntimeProgram, ParameterNamespace +from .utils import RuntimeDecoder, to_base64_string from .exceptions import (QiskitRuntimeError, RuntimeDuplicateProgramError, RuntimeProgramNotFound, RuntimeJobNotFound) from .program.result_decoder import ResultDecoder @@ -121,8 +120,9 @@ def pprint_programs(self, refresh: bool = False, detailed: bool = False) -> None if detailed: print(str(prog)) else: - print(f"Name: {prog.name}") - print(f"Description: {prog.description}") + print(f"{prog.program_id}:",) + print(f" Name: {prog.name}") + print(f" Description: {prog.description}") def programs(self, refresh: bool = False) -> List[RuntimeProgram]: """Return available runtime programs. @@ -139,7 +139,7 @@ def programs(self, refresh: bool = False) -> List[RuntimeProgram]: if not self._programs or refresh: self._programs = {} response = self._api_client.list_programs() - for prog_dict in response: + for prog_dict in response.get("programs", []): program = self._to_program(prog_dict) self._programs[program.program_id] = program return list(self._programs.values()) @@ -182,22 +182,27 @@ def _to_program(self, response: Dict) -> RuntimeProgram: Returns: A ``RuntimeProgram`` instance. """ - backend_req = json.loads(response.get('backendRequirements', '{}')) - params = json.loads(response.get('parameters', '{}')).get("doc", []) - ret_vals = json.loads(response.get('returnValues', '{}')) - interim_results = json.loads(response.get('interimResults', '{}')) + backend_requirements = {} + parameters = {} + return_values = {} + interim_results = {} + if "spec" in response: + backend_requirements = response["spec"].get('backend_requirements', {}) + parameters = response["spec"].get('parameters', {}) + return_values = response["spec"].get('return_values', {}) + interim_results = response["spec"].get('interim_results', {}) return RuntimeProgram(program_name=response['name'], program_id=response['id'], description=response.get('description', ""), - parameters=params, - return_values=ret_vals, + parameters=parameters, + return_values=return_values, interim_results=interim_results, max_execution_time=response.get('cost', 0), - creation_date=response.get('creationDate', ""), - version=response.get('version', "0"), - backend_requirements=backend_req, - is_public=response.get('isPublic', False)) + creation_date=response.get('creation_date', ""), + update_date=response.get('update_date', ""), + backend_requirements=backend_requirements, + is_public=response.get('is_public', False)) def run( self, @@ -246,12 +251,11 @@ def run( raise IBMQInputValueError('"image" needs to be in form of image_name:tag') backend_name = options['backend_name'] - params_str = json.dumps(inputs, cls=RuntimeEncoder) 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=params_str, + params=inputs, image=image) backend = self._provider.get_backend(backend_name) @@ -267,16 +271,7 @@ def run( def upload_program( self, data: str, - metadata: Optional[Union[Dict, str]] = None, - name: Optional[str] = None, - is_public: Optional[bool] = False, - max_execution_time: Optional[int] = None, - description: Optional[str] = None, - version: Optional[float] = None, - backend_requirements: Optional[str] = None, - parameters: Optional[List[ProgramParameter]] = None, - return_values: Optional[List[ProgramResult]] = None, - interim_results: Optional[List[ProgramResult]] = None + metadata: Optional[Union[Dict, str]] = None ) -> str: """Upload a runtime program. @@ -299,18 +294,23 @@ def upload_program( Args: data: Program data or path of the file containing program data to upload. metadata: Name of the program metadata file or metadata dictionary. - A metadata file needs to be in the JSON format. - See :file:`program/program_metadata_sample.yaml` for an example. - name: Name of the program. Required if not specified via `metadata`. - max_execution_time: Maximum execution time in seconds. Required if - not specified via `metadata`. - is_public: Whether the runtime program should be visible to the public. - description: Program description. Required if not specified via `metadata`. - version: Program version. The default is 1.0 if not specified. - backend_requirements: Backend requirements. - parameters: A list of program input parameters. - return_values: A list of program return values. - interim_results: A list of program interim results. + A metadata file needs to be in the JSON format. The ``parameters``, + ``return_values``, and ``interim_results`` should be defined as JSON Schema. + See :file:`program/program_metadata_sample.json` for an example. The + fields in metadata are explained below. + + * name: Name of the program. Required. + * max_execution_time: Maximum execution time in seconds. Required. + * description: Program description. Required. + * is_public: Whether the runtime program should be visible to the public. + The default is ``False``. + * spec: Specifications for backend characteristics and input parameters + required to run the program, interim results and final result. + + * backend_requirements: Backend requirements. + * parameters: Program input parameters in JSON schema format. + * return_values: Program return values in JSON schema format. + * interim_results: Program interim results in JSON schema format. Returns: Program ID. @@ -321,14 +321,7 @@ def upload_program( IBMQNotAuthorizedError: If you are not authorized to upload programs. QiskitRuntimeError: If the upload failed. """ - program_metadata = self._merge_metadata( - initial={}, - metadata=metadata, - name=name, max_execution_time=max_execution_time, - is_public=is_public, description=description, - version=version, backend_requirements=backend_requirements, - parameters=parameters, - return_values=return_values, interim_results=interim_results) + program_metadata = self._read_metadata(metadata=metadata) for req in ['name', 'description', 'max_execution_time']: if req not in program_metadata or not program_metadata[req]: @@ -340,7 +333,8 @@ def upload_program( data = file.read() try: - response = self._api_client.program_create(program_data=data.encode(), + program_data = to_base64_string(data) + response = self._api_client.program_create(program_data=program_data, **program_metadata) except RequestsApiError as ex: if ex.status_code == 409: @@ -352,21 +346,17 @@ def upload_program( raise QiskitRuntimeError(f"Failed to create program: {ex}") from None return response['id'] - def _merge_metadata( + def _read_metadata( self, - initial: Dict, - metadata: Optional[Union[Dict, str]] = None, - **kwargs: Any + metadata: Optional[Union[Dict, str]] = None ) -> Dict: - """Merge multiple copies of metadata. + """Read metadata. Args: - initial: The initial metadata. This may be mutated. metadata: Name of the program metadata file or metadata dictionary. - **kwargs: Additional metadata fields to overwrite. Returns: - Merged metadata. + Return metadata. """ upd_metadata: dict = {} if metadata is not None: @@ -374,33 +364,11 @@ def _merge_metadata( with open(metadata, 'r') as file: upd_metadata = json.load(file) else: - upd_metadata = copy.deepcopy(metadata) - - self._tuple_to_dict(initial) - initial.update(upd_metadata) - - self._tuple_to_dict(kwargs) - for key, val in kwargs.items(): - if val is not None: - initial[key] = val - + upd_metadata = metadata # TODO validate metadata format - metadata_keys = ['name', 'max_execution_time', 'description', 'version', - 'backend_requirements', 'parameters', 'return_values', - 'interim_results', 'is_public'] - return {key: val for key, val in initial.items() if key in metadata_keys} - - def _tuple_to_dict(self, metadata: Dict) -> None: - """Convert fields in metadata from named tuples to dictionaries. - - Args: - metadata: Metadata to be converted. - """ - for key in ['parameters', 'return_values', 'interim_results']: - doc_list = metadata.pop(key, None) - if not doc_list or isinstance(doc_list[0], dict): - continue - metadata[key] = [dict(elem._asdict()) for elem in doc_list] + metadata_keys = ['name', 'max_execution_time', 'description', + 'spec', 'is_public'] + return {key: val for key, val in upd_metadata.items() if key in metadata_keys} def update_program( self, @@ -412,12 +380,22 @@ def update_program( Args: program_id: Program ID. data: Program data or path of the file containing program data to upload. + + Raises: + RuntimeProgramNotFound: If the program doesn't exist. + QiskitRuntimeError: If the request failed. """ if "def main(" not in data: # This is the program file with open(data, "r") as file: data = file.read() - self._api_client.program_update(program_id, data) + try: + program_data = to_base64_string(data) + self._api_client.program_update(program_id, program_data) + except RequestsApiError as ex: + if ex.status_code == 404: + raise RuntimeProgramNotFound(f"Program not found: {ex.message}") from None + raise QiskitRuntimeError(f"Failed to update program: {ex}") from None def delete_program(self, program_id: str) -> None: """Delete a runtime program. diff --git a/qiskit/providers/ibmq/runtime/program/program_metadata_sample.json b/qiskit/providers/ibmq/runtime/program/program_metadata_sample.json index a38c18fd6..6449b474b 100644 --- a/qiskit/providers/ibmq/runtime/program/program_metadata_sample.json +++ b/qiskit/providers/ibmq/runtime/program/program_metadata_sample.json @@ -2,16 +2,38 @@ "name": "runtime-simple", "description": "Simple runtime program used for testing.", "max_execution_time": 300, - "version": 1.0, - "backend_requirements": {"min_num_qubits": 5}, - "parameters": [ - {"name": "iterations", "description": "Number of iterations to run. Each iteration generates a runs a random circuit.", "type": "integer", "required": true} - ], - "return_values": [ - {"name": "-", "description": "A string that says 'All done!'.", "type": "string"} - ], - "interim_results": [ - {"name": "iteration", "description": "Iteration number.", "type": "int"}, - {"name": "counts", "description": "Histogram data of the circuit result.", "type": "dict"} - ] + "spec": { + "backend_requirements": { + "min_num_qubits": 5 + }, + "parameters": { + "type": "object", + "properties": { + "iterations": { + "description": "Number of iterations to run. Each iteration generates and runs a random circuit.", + "type": "integer" + } + }, + "required": [ + "iterations" + ] + }, + "return_values": { + "type": "string", + "description": "A string that says 'All done!'." + }, + "interim_results": { + "type": "object", + "properties": { + "iteration": { + "description": "Iteration number.", + "type": "integer" + }, + "counts": { + "description": "Histogram data of the circuit result.", + "type": "object" + } + } + } + } } \ No newline at end of file diff --git a/qiskit/providers/ibmq/runtime/runtime_program.py b/qiskit/providers/ibmq/runtime/runtime_program.py index ee439282b..b769886ec 100644 --- a/qiskit/providers/ibmq/runtime/runtime_program.py +++ b/qiskit/providers/ibmq/runtime/runtime_program.py @@ -13,7 +13,8 @@ """Qiskit runtime program.""" import logging -from typing import Optional, List, NamedTuple, Dict +import re +from typing import Optional, List, Dict from types import SimpleNamespace from qiskit.providers.ibmq.exceptions import IBMQInputValueError @@ -47,13 +48,13 @@ def __init__( program_name: str, program_id: str, description: str, - parameters: Optional[List] = None, - return_values: Optional[List] = None, - interim_results: Optional[List] = None, + parameters: Optional[Dict] = None, + return_values: Optional[Dict] = None, + interim_results: Optional[Dict] = None, max_execution_time: int = 0, - version: str = "0", backend_requirements: Optional[Dict] = None, creation_date: str = "", + update_date: str = "", is_public: Optional[bool] = False ) -> None: """RuntimeProgram constructor. @@ -66,59 +67,54 @@ def __init__( return_values: Documentation on program return values. interim_results: Documentation on program interim results. max_execution_time: Maximum execution time. - version: Program version. backend_requirements: Backend requirements. creation_date: Program creation date. + update_date: Program last updated date. is_public: ``True`` if program is visible to all. ``False`` if it's only visible to you. """ self._name = program_name self._id = program_id self._description = description self._max_execution_time = max_execution_time - self._version = version self._backend_requirements = backend_requirements or {} - self._parameters: List[ProgramParameter] = [] - self._return_values: List[ProgramResult] = [] - self._interim_results: List[ProgramResult] = [] + self._parameters = parameters or {} + self._return_values = return_values or {} + self._interim_results = interim_results or {} self._creation_date = creation_date + self._update_date = update_date self._is_public = is_public - if parameters: - for param in parameters: - self._parameters.append( - ProgramParameter(name=param['name'], - description=param['description'], - type=param['type'], - required=param['required'])) - if return_values is not None: - for ret in return_values: - self._return_values.append(ProgramResult(name=ret['name'], - description=ret['description'], - type=ret['type'])) - if interim_results is not None: - for intret in interim_results: - self._interim_results.append(ProgramResult(name=intret['name'], - description=intret['description'], - type=intret['type'])) - def __str__(self) -> str: - def _format_common(items: List) -> None: - """Add name, description, and type to `formatted`.""" - for item in items: - formatted.append(" "*4 + "- " + item.name + ":") - formatted.append(" "*6 + "Description: " + item.description) - formatted.append(" "*6 + "Type: " + item.type) - if hasattr(item, 'required'): - formatted.append(" "*6 + "Required: " + str(item.required)) + def _format_common(schema: Dict) -> None: + """Add title, description and property details to `formatted`.""" + if "description" in schema: + formatted.append(" "*4 + "Description: {}".format(schema["description"])) + if "type" in schema: + formatted.append(" "*4 + "Type: {}".format(str(schema["type"]))) + if "properties" in schema: + formatted.append(" "*4 + "Properties:") + for property_name, property_value in schema["properties"].items(): + formatted.append(" "*8 + "- " + property_name + ":") + for key, value in property_value.items(): + formatted.append(" "*12 + "{}: {}".format(sentence_case(key), str(value))) + formatted.append(" "*12 + "Required: " + + str(property_name in schema.get("required", []))) + + def sentence_case(camel_case_text: str) -> str: + """Converts camelCase to Sentence case""" + if camel_case_text == '': + return camel_case_text + sentence_case_text = re.sub('([A-Z])', r' \1', camel_case_text) + return sentence_case_text[:1].upper() + sentence_case_text[1:].lower() formatted = [f'{self.program_id}:', f" Name: {self.name}", f" Description: {self.description}", - f" Version: {self.version}", f" Creation date: {self.creation_date}", - f" Max execution time: {self.max_execution_time}", - f" Input parameters:"] + f" Update date: {self.update_date}", + f" Max execution time: {self.max_execution_time}"] + formatted.append(" Input parameters:") if self._parameters: _format_common(self._parameters) else: @@ -148,7 +144,6 @@ def to_dict(self) -> Dict: "name": self.name, "description": self.description, "max_execution_time": self.max_execution_time, - "version": self.version, "backend_requirements": self.backend_requirements, "parameters": self.parameters(), "return_values": self.return_values, @@ -199,7 +194,7 @@ def description(self) -> str: return self._description @property - def return_values(self) -> List['ProgramResult']: + def return_values(self) -> Dict: """Program return value definitions. Returns: @@ -208,7 +203,7 @@ def return_values(self) -> List['ProgramResult']: return self._return_values @property - def interim_results(self) -> List['ProgramResult']: + def interim_results(self) -> Dict: """Program interim result definitions. Returns: @@ -227,15 +222,6 @@ def max_execution_time(self) -> int: """ return self._max_execution_time - @property - def version(self) -> str: - """Program version. - - Returns: - Program version. - """ - return self._version - @property def backend_requirements(self) -> Dict: """Backend requirements. @@ -254,6 +240,15 @@ def creation_date(self) -> str: """ return self._creation_date + @property + def update_date(self) -> str: + """Program last updated date. + + Returns: + Program last updated date. + """ + return self._update_date + @property def is_public(self) -> bool: """Whether the program is visible to all. @@ -264,21 +259,6 @@ def is_public(self) -> bool: return self._is_public -class ProgramParameter(NamedTuple): - """Program parameter.""" - name: str - description: str - type: str - required: bool - - -class ProgramResult(NamedTuple): - """Program result.""" - name: str - description: str - type: str - - class ParameterNamespace(SimpleNamespace): """ A namespace for program parameters with validation. @@ -286,26 +266,26 @@ class ParameterNamespace(SimpleNamespace): and validation support. """ - def __init__(self, params: List[ProgramParameter]): + def __init__(self, parameters: Dict): """ParameterNamespace constructor. Args: - params: The program's input parameters. + parameters: The program's input parameters. """ super().__init__() - # Allow access to the raw program parameters list - self.__metadata = params + # Allow access to the raw program parameters dict + self.__metadata = parameters # For localized logic, create store of parameters in dictionary self.__program_params: dict = {} - for param in params: + for parameter_name, parameter_value in parameters.get("properties", {}).items(): # (1) Add parameters to a dict by name - setattr(self, param.name, None) + setattr(self, parameter_name, None) # (2) Store the program params for validation - self.__program_params[param.name] = param + self.__program_params[parameter_name] = parameter_value @property - def metadata(self) -> List[ProgramParameter]: + def metadata(self) -> Dict: """Returns the parameter metadata""" return self.__metadata @@ -321,12 +301,12 @@ def validate(self) -> None: """ # Iterate through the user's stored inputs - for param_name, program_param in self.__program_params.items(): - # Set invariants: User-specified parameter value (value) and whether it's required (req) - value = getattr(self, param_name, None) + for parameter_name, parameter_value in self.__program_params.items(): + # Set invariants: User-specified parameter value (value) and if it's required (req) + value = getattr(self, parameter_name, None) # Check there exists a program parameter of that name. - if value is None and program_param.required: - raise IBMQInputValueError('Param (%s) missing required value!' % param_name) + if value is None and parameter_name in self.metadata.get("required", []): + raise IBMQInputValueError('Param (%s) missing required value!' % parameter_name) def __str__(self) -> str: """Creates string representation of object""" @@ -339,15 +319,14 @@ def __str__(self) -> str: 'Required', 'Description' ) - # List of ProgramParameter objects (str) params_str = '\n'.join([ '| {:10.10} | {:12.12} | {:12.12}| {:8.8} | {:>15} |'.format( - param.name, - str(getattr(self, param.name, 'None')), - param.type, - str(param.required), - param.description - ) for param in self.__program_params.values()]) + parameter_name, + str(getattr(self, parameter_name, "None")), + str(parameter_value.get("type", "None")), + str(parameter_name in self.metadata.get("required", [])), + str(parameter_value.get("description", "None")) + ) for parameter_name, parameter_value in self.__program_params.items()]) return "ParameterNamespace (Values):\n%s\n%s\n%s" \ % (header, '-' * len(header), params_str) diff --git a/qiskit/providers/ibmq/runtime/utils.py b/qiskit/providers/ibmq/runtime/utils.py index 9290dfaee..18e8ff8b4 100644 --- a/qiskit/providers/ibmq/runtime/utils.py +++ b/qiskit/providers/ibmq/runtime/utils.py @@ -40,6 +40,18 @@ from qiskit.result import Result +def to_base64_string(data: str) -> str: + """Convert string to base64 string. + + Args: + data: string to convert + + Returns: + data as base64 string + """ + return base64.b64encode(data.encode('utf-8')).decode('utf-8') + + def _serialize_and_encode( data: Any, serializer: Callable, diff --git a/releasenotes/notes/feature-program-update-date-7325797d7abd36ad.yaml b/releasenotes/notes/feature-program-update-date-7325797d7abd36ad.yaml new file mode 100644 index 000000000..05c312841 --- /dev/null +++ b/releasenotes/notes/feature-program-update-date-7325797d7abd36ad.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + You can view the last updated date of a runtime program using + :attr:`~qiskit.providers.ibmq.runtime.RuntimeProgram.update_date` property. diff --git a/releasenotes/notes/remove-version-field-0543061d4a7e059a.yaml b/releasenotes/notes/remove-version-field-0543061d4a7e059a.yaml new file mode 100644 index 000000000..099dcd222 --- /dev/null +++ b/releasenotes/notes/remove-version-field-0543061d4a7e059a.yaml @@ -0,0 +1,4 @@ +--- +upgrade: + - | + Runtime programs will no longer have a ``version`` field. diff --git a/releasenotes/notes/upgrade-metadata-json-schema-46f034ada7443cf9.yaml b/releasenotes/notes/upgrade-metadata-json-schema-46f034ada7443cf9.yaml new file mode 100644 index 000000000..dcc379f66 --- /dev/null +++ b/releasenotes/notes/upgrade-metadata-json-schema-46f034ada7443cf9.yaml @@ -0,0 +1,10 @@ +--- +upgrade: + - | + :meth:`qiskit.provider.ibmq.runtime.IBMRuntimeService.upload_program` now takes only two + parameters, ``data``, which is the program passed as a string or the path to the program + file and the ``metadata``, which is passed as a dictionary or path to the metadata JSON file. + In ``metadata`` the ``backend_requirements``, ``parameters``, ``return_values`` and + ``interim_results`` are now grouped under a specifications ``spec`` section. + ``parameters``, ``return_values`` and ``interim_results`` should now be specified as + JSON Schema. diff --git a/test/ibmq/runtime/fake_runtime_client.py b/test/ibmq/runtime/fake_runtime_client.py index cc017266e..e9ccebd9b 100644 --- a/test/ibmq/runtime/fake_runtime_client.py +++ b/test/ibmq/runtime/fake_runtime_client.py @@ -26,7 +26,7 @@ class BaseFakeProgram: """Base class for faking a program.""" - def __init__(self, program_id, name, data, cost, description, version="1.0", + def __init__(self, program_id, name, data, cost, description, backend_requirements=None, parameters=None, return_values=None, interim_results=None, is_public=False): """Initialize a fake program.""" @@ -35,7 +35,6 @@ def __init__(self, program_id, name, data, cost, description, version="1.0", self._data = data self._cost = cost self._description = description - self._version = version self._backend_requirements = backend_requirements self._parameters = parameters self._return_values = return_values @@ -48,18 +47,20 @@ def to_dict(self, include_data=False): 'name': self._name, 'cost': self._cost, 'description': self._description, - 'version': self._version, - 'isPublic': self._is_public} + 'is_public': self._is_public, + 'creation_date': '2021-09-13T17:27:42Z', + 'update_date': '2021-09-14T19:25:32Z'} if include_data: out['data'] = self._data + out['spec'] = {} if self._backend_requirements: - out['backendRequirements'] = json.dumps(self._backend_requirements) + out['spec']['backend_requirements'] = self._backend_requirements if self._parameters: - out['parameters'] = json.dumps({"doc": self._parameters}) + out['spec']['parameters'] = self._parameters if self._return_values: - out['returnValues'] = json.dumps(self._return_values) + out['spec']['return_values'] = self._return_values if self._interim_results: - out['interimResults'] = json.dumps(self._interim_results) + out['spec']['interim_results'] = self._interim_results return out @@ -231,25 +232,25 @@ def set_final_status(self, final_status): self._final_status = final_status def list_programs(self): - """List all progrmas.""" + """List all programs.""" programs = [] for prog in self._programs.values(): programs.append(prog.to_dict()) - return programs + return {"programs": programs} - def program_create(self, program_data, name, description, max_execution_time, version="1.0", - backend_requirements=None, parameters=None, return_values=None, - interim_results=None, is_public=False): + def program_create(self, program_data, name, description, max_execution_time, + spec=None, is_public=False): """Create a program.""" - if isinstance(program_data, str): - with open(program_data, 'rb') as file: - program_data = file.read() program_id = name if program_id in self._programs: raise RequestsApiError("Program already exists.", status_code=409) + backend_requirements = spec.get('backend_requirements', None) + parameters = spec.get('parameters', None) + return_values = spec.get('return_values', None) + interim_results = spec.get('interim_results', None) self._programs[program_id] = BaseFakeProgram( program_id=program_id, name=name, data=program_data, cost=max_execution_time, - description=description, version=version, backend_requirements=backend_requirements, + description=description, backend_requirements=backend_requirements, parameters=parameters, return_values=return_values, interim_results=interim_results, is_public=is_public) return {'id': program_id} diff --git a/test/ibmq/runtime/test_runtime.py b/test/ibmq/runtime/test_runtime.py index 5d24eb5ed..7870964bb 100644 --- a/test/ibmq/runtime/test_runtime.py +++ b/test/ibmq/runtime/test_runtime.py @@ -12,6 +12,7 @@ """Tests for runtime service.""" +import copy import json import os from io import StringIO @@ -54,8 +55,7 @@ from qiskit.providers.ibmq.runtime.constants import API_TO_JOB_ERROR_MESSAGE from qiskit.providers.ibmq.runtime.exceptions import (RuntimeProgramNotFound, RuntimeJobFailureError) -from qiskit.providers.ibmq.runtime.runtime_program import ( - ParameterNamespace, ProgramParameter, ProgramResult) +from qiskit.providers.ibmq.runtime.runtime_program import ParameterNamespace from ...ibmqtestcase import IBMQTestCase from .fake_runtime_client import (BaseFakeRuntimeClient, FailedRanTooLongRuntimeJob, @@ -70,17 +70,50 @@ class TestRuntime(IBMQTestCase): "name": "qiskit-test", "description": "Test program.", "max_execution_time": 300, - "version": "0.1", - "backend_requirements": {"min_num_qubits": 5}, - "parameters": [ - {'name': 'param1', 'description': 'Desc 1', 'type': 'str', 'required': True}, - {'name': 'param2', 'description': 'Desc 2', 'type': 'int', 'required': False}], - "return_values": [ - {"name": "ret_val", "description": "Some return value.", "type": "string"} - ], - "interim_results": [ - {"name": "int_res", "description": "Some interim result", "type": "string"}, - ] + "spec": { + "backend_requirements": { + "min_num_qubits": 5 + }, + "parameters": { + "properties": { + "param1": { + "description": "Desc 1", + "type": "string", + "enum": [ + "a", + "b", + "c" + ] + }, + "param2": { + "description": "Desc 2", + "type": "integer", + "min": 0 + } + }, + "required": [ + "param1" + ] + }, + "return_values": { + "type": "object", + "description": "Return values", + "properties": { + "ret_val": { + "description": "Some return value.", + "type": "string" + } + } + }, + "interim_results": { + "properties": { + "int_res": { + "description": "Some interim result", + "type": "string" + } + } + } + } } def setUp(self): @@ -286,13 +319,13 @@ def test_print_programs(self): for prog in programs: self.assertIn(prog.program_id, stdout) self.assertIn(prog.name, stdout) - self.assertNotIn(prog.version, stdout) + self.assertNotIn(str(prog.max_execution_time), stdout) self.runtime.pprint_programs(detailed=True) stdout_detailed = mock_stdout.getvalue() for prog in programs: self.assertIn(prog.program_id, stdout_detailed) self.assertIn(prog.name, stdout_detailed) - self.assertIn(prog.version, stdout_detailed) + self.assertIn(str(prog.max_execution_time), stdout_detailed) def test_upload_program(self): """Test uploading a program.""" @@ -600,28 +633,17 @@ def test_program_metadata(self): self.assertEqual(self.DEFAULT_METADATA['description'], program.description) self.assertEqual(self.DEFAULT_METADATA['max_execution_time'], program.max_execution_time) - self.assertEqual(self.DEFAULT_METADATA["version"], program.version) - self.assertEqual(self.DEFAULT_METADATA['backend_requirements'], + self.assertTrue(program.creation_date) + self.assertTrue(program.update_date) + self.assertEqual(self.DEFAULT_METADATA['spec']['backend_requirements'], program.backend_requirements) - self.assertEqual([ProgramParameter(**param) for param in - self.DEFAULT_METADATA['parameters']], + self.assertEqual(self.DEFAULT_METADATA['spec']['parameters'], program.parameters().metadata) - self.assertEqual([ProgramResult(**ret) for ret in - self.DEFAULT_METADATA['return_values']], + self.assertEqual(self.DEFAULT_METADATA['spec']['return_values'], program.return_values) - self.assertEqual([ProgramResult(**ret) for ret in - self.DEFAULT_METADATA['interim_results']], + self.assertEqual(self.DEFAULT_METADATA['spec']['interim_results'], program.interim_results) - def test_metadata_combined(self): - """Test combining metadata""" - update_metadata = {"version": "1.2", "max_execution_time": 600} - program_id = self.runtime.upload_program( - data="def main() {}", metadata=self.DEFAULT_METADATA, **update_metadata) - program = self.runtime.program(program_id) - self.assertEqual(update_metadata['max_execution_time'], program.max_execution_time) - self.assertEqual(update_metadata["version"], program.version) - def test_different_providers(self): """Test retrieving job submitted with different provider.""" program_id = self._upload_program() @@ -638,12 +660,13 @@ def _upload_program(self, name=None, max_execution_time=300, """Upload a new program.""" name = name or uuid.uuid4().hex data = "def main() {}" + metadata = copy.deepcopy(self.DEFAULT_METADATA) + metadata.update(name=name) + metadata.update(is_public=is_public) + metadata.update(max_execution_time=max_execution_time) program_id = self.runtime.upload_program( - name=name, data=data, - is_public=is_public, - max_execution_time=max_execution_time, - description="A test program") + metadata=metadata) return program_id def _run_program(self, program_id=None, inputs=None, job_classes=None, final_status=None, diff --git a/test/ibmq/runtime/test_runtime_integration.py b/test/ibmq/runtime/test_runtime_integration.py index 0e3965608..158cd1928 100644 --- a/test/ibmq/runtime/test_runtime_integration.py +++ b/test/ibmq/runtime/test_runtime_integration.py @@ -12,6 +12,7 @@ """Tests for runtime service.""" +import copy import unittest import os import uuid @@ -84,11 +85,12 @@ def setUpClass(cls, backend): cls.backend = backend cls.poll_time = 1 if backend.configuration().simulator else 5 cls.provider = backend.provider() + metadata = copy.deepcopy(cls.RUNTIME_PROGRAM_METADATA) + metadata['name'] = cls._get_program_name() try: cls.program_id = cls.provider.runtime.upload_program( - name=cls._get_program_name(), data=cls.RUNTIME_PROGRAM, - metadata=cls.RUNTIME_PROGRAM_METADATA) + metadata=metadata) except RuntimeDuplicateProgramError: pass except IBMQNotAuthorizedError: @@ -199,13 +201,6 @@ def test_set_visibility(self): # Verify changed self.assertNotEqual(start_vis, end_vis) - def test_upload_program_conflict(self): - """Test uploading a program with conflicting name.""" - name = self._get_program_name() - self._upload_program(name=name) - with self.assertRaises(RuntimeDuplicateProgramError): - self._upload_program(name=name) - def test_delete_program(self): """Test deleting program.""" program_id = self._upload_program() @@ -625,7 +620,7 @@ def _validate_program(self, program): self.assertTrue(program.description) self.assertTrue(program.max_execution_time) self.assertTrue(program.creation_date) - self.assertTrue(program.version) + self.assertTrue(program.update_date) def _upload_program( self, @@ -636,13 +631,13 @@ def _upload_program( """Upload a new program.""" name = name or self._get_program_name() data = data or self.RUNTIME_PROGRAM + metadata = copy.deepcopy(self.RUNTIME_PROGRAM_METADATA) + metadata['name'] = name + metadata['max_execution_time'] = max_execution_time + metadata['is_public'] = is_public program_id = self.provider.runtime.upload_program( - name=name, data=data, - is_public=is_public, - metadata=self.RUNTIME_PROGRAM_METADATA, - max_execution_time=max_execution_time, - description="Qiskit test program") + metadata=metadata) self.to_delete.append(program_id) return program_id From a4f73919bcc7e4d6a9a603a0cde3870c0f54e417 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Sun, 31 Oct 2021 21:02:36 -0400 Subject: [PATCH 2/8] Allow updating runtime metadata in place (qiskit-ibm #188) (#1071) * Allow updating runtime metadata in place (#188) * update runtime metadata * return if no data * fix mypy * Update releasenotes/notes/update-runtime-metadata-d2ddbcfc0d034530.yaml Co-authored-by: Rathish Cholarajan (cherry picked from commit b532b1476046eb7faa31f6027ae2f789675a804b) * update release note --- qiskit/providers/ibmq/api/clients/runtime.py | 33 +++++---- qiskit/providers/ibmq/api/rest/runtime.py | 41 ++++++++--- .../ibmq/runtime/ibm_runtime_service.py | 68 +++++++++++++++++-- ...ate-runtime-metadata-d2ddbcfc0d034530.yaml | 9 +++ test/ibmq/runtime/fake_runtime_client.py | 35 ++++++++-- test/ibmq/runtime/test_runtime.py | 47 +++++++++++++ test/ibmq/runtime/test_runtime_integration.py | 27 +++++++- 7 files changed, 220 insertions(+), 40 deletions(-) create mode 100644 releasenotes/notes/update-runtime-metadata-d2ddbcfc0d034530.yaml diff --git a/qiskit/providers/ibmq/api/clients/runtime.py b/qiskit/providers/ibmq/api/clients/runtime.py index 452d86773..63f7e7aac 100644 --- a/qiskit/providers/ibmq/api/clients/runtime.py +++ b/qiskit/providers/ibmq/api/clients/runtime.py @@ -87,17 +87,6 @@ def program_get(self, program_id: str) -> Dict: """ return self.api.program(program_id).get() - def program_get_data(self, program_id: str) -> Dict: - """Return a specific program and its data. - - Args: - program_id: Program ID. - - Returns: - Program information, including data. - """ - return self.api.program(program_id).get_data() - def set_program_visibility(self, program_id: str, public: bool) -> None: """Sets a program's visibility. @@ -145,14 +134,32 @@ def program_delete(self, program_id: str) -> None: """ self.api.program(program_id).delete() - def program_update(self, program_id: str, program_data: str) -> None: + def program_update( + self, + program_id: str, + program_data: str = None, + name: str = None, + description: str = None, + max_execution_time: int = None, + spec: Optional[Dict] = None + ) -> None: """Update a program. Args: program_id: Program ID. program_data: Program data (base64 encoded). + name: Name of the program. + description: Program description. + max_execution_time: Maximum execution time. + spec: Backend requirements, parameters, interim results, return values, etc. """ - self.api.program(program_id).update(program_data) + if program_data: + self.api.program(program_id).update_data(program_data) + + if any([name, description, max_execution_time, spec]): + self.api.program(program_id).update_metadata( + name=name, description=description, + max_execution_time=max_execution_time, spec=spec) def job_get(self, job_id: str) -> Dict: """Get job data. diff --git a/qiskit/providers/ibmq/api/rest/runtime.py b/qiskit/providers/ibmq/api/rest/runtime.py index 382d9c5af..4e65ecf19 100644 --- a/qiskit/providers/ibmq/api/rest/runtime.py +++ b/qiskit/providers/ibmq/api/rest/runtime.py @@ -194,15 +194,6 @@ def get(self) -> Dict[str, Any]: url = self.get_url('self') return self.session.get(url).json() - def get_data(self) -> Dict[str, Any]: - """Return program information, including data. - - Returns: - JSON response. - """ - url = self.get_url('data') - return self.session.get(url).json() - def make_public(self) -> None: """Sets a runtime program's visibility to public.""" url = self.get_url('public') @@ -222,8 +213,8 @@ def delete(self) -> None: url = self.get_url('self') self.session.delete(url) - def update(self, program_data: str) -> None: - """Update a program. + def update_data(self, program_data: str) -> None: + """Update program data. Args: program_data: Program data (base64 encoded). @@ -232,6 +223,34 @@ def update(self, program_data: str) -> None: self.session.put(url, data=program_data, headers={'Content-Type': 'application/octet-stream'}) + def update_metadata( + self, + name: str = None, + description: str = None, + max_execution_time: int = None, + spec: Optional[Dict] = None + ) -> None: + """Update program metadata. + + Args: + name: Name of the program. + description: Program description. + max_execution_time: Maximum execution time. + spec: Backend requirements, parameters, interim results, return values, etc. + """ + url = self.get_url("self") + payload: Dict = {} + if name: + payload["name"] = name + if description: + payload["description"] = description + if max_execution_time: + payload["cost"] = max_execution_time + if spec: + payload["spec"] = spec + + self.session.patch(url, json=payload) + class ProgramJob(RestAdapterBase): """Rest adapter for program job related endpoints.""" diff --git a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py index b6d0a4531..ffeff2d2d 100644 --- a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py +++ b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py @@ -16,6 +16,7 @@ from typing import Dict, Callable, Optional, Union, List, Any, Type import json import re +import warnings from qiskit.providers.exceptions import QiskitBackendNotFoundError from qiskit.providers.ibmq import accountprovider # pylint: disable=unused-import @@ -373,30 +374,83 @@ def _read_metadata( def update_program( self, program_id: str, - data: str, + data: str = None, + metadata: Optional[Union[Dict, str]] = None, + name: str = None, + description: str = None, + max_execution_time: int = None, + spec: Optional[Dict] = None ) -> None: """Update a runtime program. + Program metadata can be specified using the `metadata` parameter or + individual parameters, such as `name` and `description`. If the + same metadata field is specified in both places, the individual parameter + takes precedence. + Args: program_id: Program ID. data: Program data or path of the file containing program data to upload. + metadata: Name of the program metadata file or metadata dictionary. + name: New program name. + description: New program description. + max_execution_time: New maximum execution time. + spec: New specifications for backend characteristics, input parameters, + interim results and final result. Raises: RuntimeProgramNotFound: If the program doesn't exist. QiskitRuntimeError: If the request failed. """ - if "def main(" not in data: - # This is the program file - with open(data, "r") as file: - data = file.read() + if not any([data, metadata, name, description, max_execution_time, spec]): + warnings.warn("None of the 'data', 'metadata', 'name', 'description', " + "'max_execution_time', or 'spec' parameters is specified. " + "No update is made.") + return + + if data: + if "def main(" not in data: + # This is the program file + with open(data, "r") as file: + data = file.read() + data = to_base64_string(data) + + if metadata: + metadata = self._read_metadata(metadata=metadata) + combined_metadata = self._merge_metadata( + metadata=metadata, name=name, description=description, + max_execution_time=max_execution_time, spec=spec) + try: - program_data = to_base64_string(data) - self._api_client.program_update(program_id, program_data) + self._api_client.program_update( + program_id, program_data=data, **combined_metadata) except RequestsApiError as ex: if ex.status_code == 404: raise RuntimeProgramNotFound(f"Program not found: {ex.message}") from None raise QiskitRuntimeError(f"Failed to update program: {ex}") from None + def _merge_metadata( + self, + metadata: Optional[Dict] = None, + **kwargs: Any + ) -> Dict: + """Merge multiple copies of metadata. + Args: + metadata: Program metadata. + **kwargs: Additional metadata fields to overwrite. + Returns: + Merged metadata. + """ + merged = {} + metadata = metadata or {} + metadata_keys = ['name', 'max_execution_time', 'description', 'spec'] + for key in metadata_keys: + if kwargs.get(key, None) is not None: + merged[key] = kwargs[key] + elif key in metadata.keys(): + merged[key] = metadata[key] + return merged + def delete_program(self, program_id: str) -> None: """Delete a runtime program. diff --git a/releasenotes/notes/update-runtime-metadata-d2ddbcfc0d034530.yaml b/releasenotes/notes/update-runtime-metadata-d2ddbcfc0d034530.yaml new file mode 100644 index 000000000..8b5f4a2a6 --- /dev/null +++ b/releasenotes/notes/update-runtime-metadata-d2ddbcfc0d034530.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + You can now use the :meth:`qiskit.providers.ibmq.runtime.IBMRuntimeService.update_program` + method to update the metadata for a Qiskit Runtime program. + Program metadata can be specified using the ``metadata`` parameter or + individual parameters, such as ``name`` and ``description``. If the + same metadata field is specified in both places, the individual parameter + takes precedence. diff --git a/test/ibmq/runtime/fake_runtime_client.py b/test/ibmq/runtime/fake_runtime_client.py index e9ccebd9b..085dcb83b 100644 --- a/test/ibmq/runtime/fake_runtime_client.py +++ b/test/ibmq/runtime/fake_runtime_client.py @@ -15,7 +15,8 @@ import time import uuid import json -from typing import Optional +import base64 +from typing import Optional, Dict from concurrent.futures import ThreadPoolExecutor from qiskit.providers.ibmq.credentials import Credentials @@ -51,7 +52,7 @@ def to_dict(self, include_data=False): 'creation_date': '2021-09-13T17:27:42Z', 'update_date': '2021-09-14T19:25:32Z'} if include_data: - out['data'] = self._data + out['data'] = base64.standard_b64decode(self._data).decode() out['spec'] = {} if self._backend_requirements: out['spec']['backend_requirements'] = self._backend_requirements @@ -255,15 +256,35 @@ def program_create(self, program_data, name, description, max_execution_time, is_public=is_public) return {'id': program_id} + def program_update( + self, + program_id: str, + program_data: str = None, + name: str = None, + description: str = None, + max_execution_time: int = None, + spec: Optional[Dict] = None + ) -> None: + """Update a program.""" + if program_id not in self._programs: + raise RequestsApiError("Program not found", status_code=404) + program = self._programs[program_id] + program._data = program_data or program._data + program._name = name or program._name + program._description = description or program._description + program._cost = max_execution_time or program._cost + if spec: + program._backend_requirements = \ + spec.get("backend_requirements") or program._backend_requirements + program._parameters = spec.get("parameters") or program._parameters + program._return_values = spec.get("return_values") or program._return_values + program._interim_results = spec.get("interim_results") or program._interim_results + def program_get(self, program_id: str): """Return a specific program.""" if program_id not in self._programs: raise RequestsApiError("Program not found", status_code=404) - return self._programs[program_id].to_dict() - - def program_get_data(self, program_id: str): - """Return a specific program and its data.""" - return self._programs[program_id].to_dict(iclude_data=True) + return self._programs[program_id].to_dict(include_data=True) def program_run( self, diff --git a/test/ibmq/runtime/test_runtime.py b/test/ibmq/runtime/test_runtime.py index 7870964bb..df215d7a0 100644 --- a/test/ibmq/runtime/test_runtime.py +++ b/test/ibmq/runtime/test_runtime.py @@ -339,6 +339,53 @@ def test_upload_program(self): self.assertEqual(max_execution_time, program.max_execution_time) self.assertEqual(program.is_public, is_public) + def test_update_program(self): + """Test updating program.""" + new_data = "def main() {foo=bar}" + new_metadata = copy.deepcopy(self.DEFAULT_METADATA) + new_metadata["name"] = "test_update_program" + new_name = "name2" + new_description = "some other description" + new_cost = self.DEFAULT_METADATA["max_execution_time"] + 100 + new_spec = copy.deepcopy(self.DEFAULT_METADATA["spec"]) + new_spec["backend_requirements"] = {"input_allowed": "runtime"} + + sub_tests = [ + {"data": new_data}, + {"metadata": new_metadata}, + {"data": new_data, "metadata": new_metadata}, + {"metadata": new_metadata, "name": new_name}, + {"data": new_data, "metadata": new_metadata, "description": new_description}, + {"max_execution_time": new_cost, "spec": new_spec} + ] + + for new_vals in sub_tests: + with self.subTest(new_vals=new_vals.keys()): + program_id = self._upload_program() + self.runtime.update_program(program_id=program_id, **new_vals) + updated = self.runtime.program(program_id, refresh=True) + if "data" in new_vals: + raw_program = self.runtime._api_client.program_get(program_id) + self.assertEqual(new_data, raw_program["data"]) + if "metadata" in new_vals and "name" not in new_vals: + self.assertEqual(new_metadata["name"], updated.name) + if "name" in new_vals: + self.assertEqual(new_name, updated.name) + if "description" in new_vals: + self.assertEqual(new_description, updated.description) + if "max_execution_time" in new_vals: + self.assertEqual(new_cost, updated.max_execution_time) + if "spec" in new_vals: + raw_program = self.runtime._api_client.program_get(program_id) + self.assertEqual(new_spec, raw_program["spec"]) + + def test_update_program_no_new_fields(self): + """Test updating a program without any new data.""" + program_id = self._upload_program() + with warnings.catch_warnings(record=True) as warn_cm: + self.runtime.update_program(program_id=program_id) + self.assertEqual(len(warn_cm), 1) + def test_delete_program(self): """Test deleting program.""" program_id = self._upload_program() diff --git a/test/ibmq/runtime/test_runtime_integration.py b/test/ibmq/runtime/test_runtime_integration.py index 158cd1928..17f65d006 100644 --- a/test/ibmq/runtime/test_runtime_integration.py +++ b/test/ibmq/runtime/test_runtime_integration.py @@ -215,8 +215,8 @@ def test_double_delete_program(self): with self.assertRaises(RuntimeProgramNotFound): self.provider.runtime.delete_program(program_id) - def test_update_program(self): - """Test updating a program.""" + def test_update_program_data(self): + """Test updating program data.""" program_v1 = """ def main(backend, user_messenger, **kwargs): return "version 1" @@ -225,6 +225,7 @@ def main(backend, user_messenger, **kwargs): def main(backend, user_messenger, **kwargs): return "version 2" """ + # TODO retrieve program data instead of run program when #66 is merged program_id = self._upload_program(data=program_v1) job = self._run_program(program_id=program_id) self.assertEqual("version 1", job.result()) @@ -232,6 +233,28 @@ def main(backend, user_messenger, **kwargs): job = self._run_program(program_id=program_id) self.assertEqual("version 2", job.result()) + def test_update_program_metadata(self): + """Test updating program metadata.""" + program_id = self._upload_program() + original = self.provider.runtime.program(program_id) + new_metadata = { + "name": self._get_program_name(), + "description": "test_update_program_metadata", + "max_execution_time": original.max_execution_time + 100, + "spec": { + "return_values": { + "type": "object", + "description": "Some return value" + } + } + } + self.provider.runtime.update_program(program_id=program_id, metadata=new_metadata) + updated = self.provider.runtime.program(program_id, refresh=True) + self.assertEqual(new_metadata["name"], updated.name) + self.assertEqual(new_metadata["description"], updated.description) + self.assertEqual(new_metadata["max_execution_time"], updated.max_execution_time) + self.assertEqual(new_metadata["spec"]["return_values"], updated.return_values) + def test_run_program(self): """Test running a program.""" job = self._run_program(final_result="foo") From 4312faee74fae4300ffc11483ddff47625c56081 Mon Sep 17 00:00:00 2001 From: Rathish Cholarajan Date: Thu, 28 Oct 2021 14:28:21 -0400 Subject: [PATCH 3/8] Use count to reduce one last extra call to API (#172) --- .../ibmq/runtime/ibm_runtime_service.py | 19 +++++++++++++------ test/ibmq/runtime/fake_runtime_client.py | 4 +++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py index ffeff2d2d..7ffe2a7a4 100644 --- a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py +++ b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py @@ -531,17 +531,24 @@ def jobs( """ job_responses = [] # type: List[Dict[str, Any]] current_page_limit = limit or 20 + offset = skip while True: - job_page = self._api_client.jobs_get( + jobs_response = self._api_client.jobs_get( limit=current_page_limit, - skip=skip, - pending=pending)["jobs"] - if not job_page: + skip=offset, + pending=pending) + job_page = jobs_response["jobs"] + # count is the total number of jobs that would be returned if + # there was no limit or skip + count = jobs_response["count"] + + job_responses += job_page + + if len(job_responses) == count - skip: # Stop if there are no more jobs returned by the server. break - job_responses += job_page if limit: if len(job_responses) >= limit: # Stop if we have reached the limit. @@ -550,7 +557,7 @@ def jobs( else: current_page_limit = 20 - skip += len(job_page) + offset += len(job_page) return [self._decode_job(job) for job in job_responses] diff --git a/test/ibmq/runtime/fake_runtime_client.py b/test/ibmq/runtime/fake_runtime_client.py index 085dcb83b..af1388983 100644 --- a/test/ibmq/runtime/fake_runtime_client.py +++ b/test/ibmq/runtime/fake_runtime_client.py @@ -322,12 +322,14 @@ def jobs_get(self, limit=None, skip=None, pending=None): limit = limit or len(self._jobs) skip = skip or 0 jobs = list(self._jobs.values()) + count = len(self._jobs) if pending is not None: job_status_list = pending_statuses if pending else returned_statuses jobs = [job for job in jobs if job._status in job_status_list] + count = len(jobs) jobs = jobs[skip:limit+skip] return {"jobs": [job.to_dict() for job in jobs], - "count": len(self._jobs)} + "count": count} def set_program_visibility(self, program_id: str, public: bool) -> None: """Sets a program's visibility. From e9d216ac9e4b93ba0a4ce7f496c90f6699b656d2 Mon Sep 17 00:00:00 2001 From: Rathish Cholarajan Date: Thu, 4 Nov 2021 11:47:27 -0400 Subject: [PATCH 4/8] Allow filtering runtime jobs by program ID (#1082) --- qiskit/providers/ibmq/api/clients/runtime.py | 11 +++++++++-- qiskit/providers/ibmq/api/rest/runtime.py | 11 ++++++++++- qiskit/providers/ibmq/runtime/ibm_runtime_service.py | 7 +++++-- ...e-filter-jobs-by-program-id-e7ef435bed1081be.yaml | 6 ++++++ test/ibmq/runtime/fake_runtime_client.py | 5 ++++- test/ibmq/runtime/test_runtime.py | 12 ++++++++++++ test/ibmq/runtime/test_runtime_integration.py | 9 +++++++++ 7 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 releasenotes/notes/feature-filter-jobs-by-program-id-e7ef435bed1081be.yaml diff --git a/qiskit/providers/ibmq/api/clients/runtime.py b/qiskit/providers/ibmq/api/clients/runtime.py index 63f7e7aac..bb27ea150 100644 --- a/qiskit/providers/ibmq/api/clients/runtime.py +++ b/qiskit/providers/ibmq/api/clients/runtime.py @@ -174,7 +174,13 @@ def job_get(self, job_id: str) -> Dict: logger.debug("Runtime job get response: %s", response) return response - def jobs_get(self, limit: int = None, skip: int = None, pending: bool = None) -> Dict: + def jobs_get( + self, + limit: int = None, + skip: int = None, + pending: bool = None, + program_id: str = None + ) -> Dict: """Get job data for all jobs. Args: @@ -182,11 +188,12 @@ def jobs_get(self, limit: int = None, skip: int = None, pending: bool = None) -> skip: Number of results to skip. pending: Returns 'QUEUED' and 'RUNNING' jobs if True, returns 'DONE', 'CANCELLED' and 'ERROR' jobs if False. + program_id: Filter by Program ID. Returns: JSON response. """ - return self.api.jobs_get(limit=limit, skip=skip, pending=pending) + return self.api.jobs_get(limit=limit, skip=skip, pending=pending, program_id=program_id) def job_results(self, job_id: str) -> str: """Get the results of a program job. diff --git a/qiskit/providers/ibmq/api/rest/runtime.py b/qiskit/providers/ibmq/api/rest/runtime.py index 4e65ecf19..935538883 100644 --- a/qiskit/providers/ibmq/api/rest/runtime.py +++ b/qiskit/providers/ibmq/api/rest/runtime.py @@ -134,7 +134,13 @@ def program_run( data = json.dumps(payload, cls=RuntimeEncoder) return self.session.post(url, data=data).json() - def jobs_get(self, limit: int = None, skip: int = None, pending: bool = None) -> Dict: + def jobs_get( + self, + limit: int = None, + skip: int = None, + pending: bool = None, + program_id: str = None + ) -> Dict: """Get a list of job data. Args: @@ -142,6 +148,7 @@ def jobs_get(self, limit: int = None, skip: int = None, pending: bool = None) -> skip: Number of results to skip. pending: Returns 'QUEUED' and 'RUNNING' jobs if True, returns 'DONE', 'CANCELLED' and 'ERROR' jobs if False. + program_id: Filter by Program ID. Returns: JSON response. @@ -154,6 +161,8 @@ def jobs_get(self, limit: int = None, skip: int = None, pending: bool = None) -> payload['offset'] = skip if pending is not None: payload['pending'] = 'true' if pending else 'false' + if program_id: + payload['program'] = program_id return self.session.get(url, params=payload).json() def logout(self) -> None: diff --git a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py index 7ffe2a7a4..c66a736bd 100644 --- a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py +++ b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py @@ -515,7 +515,8 @@ def jobs( self, limit: Optional[int] = 10, skip: int = 0, - pending: bool = None + pending: bool = None, + program_id: str = None ) -> List[RuntimeJob]: """Retrieve all runtime jobs, subject to optional filtering. @@ -525,6 +526,7 @@ def jobs( pending: Filter by job pending state. If ``True``, 'QUEUED' and 'RUNNING' jobs are included. If ``False``, 'DONE', 'CANCELLED' and 'ERROR' jobs are included. + program_id: Filter by Program ID. Returns: A list of runtime jobs. @@ -537,7 +539,8 @@ def jobs( jobs_response = self._api_client.jobs_get( limit=current_page_limit, skip=offset, - pending=pending) + pending=pending, + program_id=program_id) job_page = jobs_response["jobs"] # count is the total number of jobs that would be returned if # there was no limit or skip diff --git a/releasenotes/notes/feature-filter-jobs-by-program-id-e7ef435bed1081be.yaml b/releasenotes/notes/feature-filter-jobs-by-program-id-e7ef435bed1081be.yaml new file mode 100644 index 000000000..ba34642c0 --- /dev/null +++ b/releasenotes/notes/feature-filter-jobs-by-program-id-e7ef435bed1081be.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + You can now pass ``program_id`` parameter to + :meth:`qiskit.providers.ibmq.runtime.IBMRuntimeService.jobs` + method to filter jobs by Program ID. diff --git a/test/ibmq/runtime/fake_runtime_client.py b/test/ibmq/runtime/fake_runtime_client.py index af1388983..dbb7be5f6 100644 --- a/test/ibmq/runtime/fake_runtime_client.py +++ b/test/ibmq/runtime/fake_runtime_client.py @@ -315,7 +315,7 @@ def job_get(self, job_id): """Get the specific job.""" return self._get_job(job_id).to_dict() - def jobs_get(self, limit=None, skip=None, pending=None): + def jobs_get(self, limit=None, skip=None, pending=None, program_id=None): """Get all jobs.""" pending_statuses = ['QUEUED', 'RUNNING'] returned_statuses = ['COMPLETED', 'FAILED', 'CANCELLED'] @@ -327,6 +327,9 @@ def jobs_get(self, limit=None, skip=None, pending=None): job_status_list = pending_statuses if pending else returned_statuses jobs = [job for job in jobs if job._status in job_status_list] count = len(jobs) + if program_id: + jobs = [job for job in jobs if job._program_id == program_id] + count = len(jobs) jobs = jobs[skip:limit+skip] return {"jobs": [job.to_dict() for job in jobs], "count": count} diff --git a/test/ibmq/runtime/test_runtime.py b/test/ibmq/runtime/test_runtime.py index df215d7a0..6697d4379 100644 --- a/test/ibmq/runtime/test_runtime.py +++ b/test/ibmq/runtime/test_runtime.py @@ -600,6 +600,18 @@ def test_jobs_limit_skip_returned(self): rjobs = self.runtime.jobs(limit=limit, skip=skip, pending=False) self.assertEqual(limit, len(rjobs)) + def test_jobs_filter_by_program_id(self): + """Test retrieving jobs by Program ID.""" + program_id = self._upload_program() + program_id_1 = self._upload_program() + job = self._run_program(program_id=program_id) + job_1 = self._run_program(program_id=program_id_1) + job.wait_for_final_state() + job_1.wait_for_final_state() + rjobs = self.runtime.jobs(program_id=program_id) + self.assertEqual(program_id, rjobs[0].program_id) + self.assertEqual(1, len(rjobs)) + def test_cancel_job(self): """Test canceling a job.""" job = self._run_program(job_classes=CancelableRuntimeJob) diff --git a/test/ibmq/runtime/test_runtime_integration.py b/test/ibmq/runtime/test_runtime_integration.py index 17f65d006..8f60edc7f 100644 --- a/test/ibmq/runtime/test_runtime_integration.py +++ b/test/ibmq/runtime/test_runtime_integration.py @@ -375,6 +375,15 @@ def test_retrieve_returned_jobs(self): break self.assertTrue(found, f"Returned job {job.job_id()} not retrieved.") + def test_retrieve_jobs_by_program_id(self): + """Test retrieving jobs by Program ID.""" + program_id = self._upload_program() + job = self._run_program(program_id=program_id) + job.wait_for_final_state() + rjobs = self.provider.runtime.jobs(program_id=program_id) + self.assertEqual(program_id, rjobs[0].program_id) + self.assertEqual(1, len(rjobs)) + def test_cancel_job_queued(self): """Test canceling a queued job.""" _ = self._run_program(iterations=10) From c31cfee1349424aaa2b638a2fddbc002f0624566 Mon Sep 17 00:00:00 2001 From: Rathish Cholarajan Date: Thu, 4 Nov 2021 13:30:22 -0400 Subject: [PATCH 5/8] Allow runtime program authors to retrieve program data (#174) (#1083) * retrieve program data * refetch once if no program data * remove unused import * refresh program on data property * fix lint * Update qiskit_ibm/runtime/runtime_program.py Co-authored-by: Rathish Cholarajan * Update qiskit_ibm/runtime/runtime_program.py Co-authored-by: Rathish Cholarajan * Update qiskit_ibm/runtime/runtime_program.py Co-authored-by: Rathish Cholarajan * add test case * add test case * add default data constant * add _validate_program method * Update test/ibm/runtime/test_runtime.py Co-authored-by: Rathish Cholarajan --- .../ibmq/runtime/ibm_runtime_service.py | 4 +- .../providers/ibmq/runtime/runtime_program.py | 47 ++++++++++++++++-- ...ogram-data-retrieval-9a9782eb16274593.yaml | 6 +++ test/ibmq/runtime/test_runtime.py | 49 ++++++++++++------- test/ibmq/runtime/test_runtime_integration.py | 13 +++++ 5 files changed, 97 insertions(+), 22 deletions(-) create mode 100644 releasenotes/notes/program-data-retrieval-9a9782eb16274593.yaml diff --git a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py index c66a736bd..78822a353 100644 --- a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py +++ b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py @@ -203,7 +203,9 @@ def _to_program(self, response: Dict) -> RuntimeProgram: creation_date=response.get('creation_date', ""), update_date=response.get('update_date', ""), backend_requirements=backend_requirements, - is_public=response.get('is_public', False)) + is_public=response.get('is_public', False), + data=response.get('data', ""), + api_client=self._api_client) def run( self, diff --git a/qiskit/providers/ibmq/runtime/runtime_program.py b/qiskit/providers/ibmq/runtime/runtime_program.py index b769886ec..786a86a44 100644 --- a/qiskit/providers/ibmq/runtime/runtime_program.py +++ b/qiskit/providers/ibmq/runtime/runtime_program.py @@ -16,8 +16,8 @@ import re from typing import Optional, List, Dict from types import SimpleNamespace -from qiskit.providers.ibmq.exceptions import IBMQInputValueError - +from qiskit.providers.ibmq.exceptions import IBMQInputValueError, IBMQNotAuthorizedError +from ..api.clients.runtime import RuntimeClient logger = logging.getLogger(__name__) @@ -55,7 +55,9 @@ def __init__( backend_requirements: Optional[Dict] = None, creation_date: str = "", update_date: str = "", - is_public: Optional[bool] = False + is_public: Optional[bool] = False, + data: str = "", + api_client: Optional[RuntimeClient] = None ) -> None: """RuntimeProgram constructor. @@ -71,6 +73,8 @@ def __init__( creation_date: Program creation date. update_date: Program last updated date. is_public: ``True`` if program is visible to all. ``False`` if it's only visible to you. + data: Program data. + api_client: Runtime api client. """ self._name = program_name self._id = program_id @@ -83,6 +87,8 @@ def __init__( self._creation_date = creation_date self._update_date = update_date self._is_public = is_public + self._data = data + self._api_client = api_client def __str__(self) -> str: def _format_common(schema: Dict) -> None: @@ -258,6 +264,41 @@ def is_public(self) -> bool: """ return self._is_public + @property + def data(self) -> str: + """Program data. + + Returns: + Program data. + + Raises: + IBMQNotAuthorizedError: if user is not the program author. + """ + if not self._data: + response = self._api_client.program_get(self._id) + self._backend_requirements = {} + self._parameters = {} + self._return_values = {} + self._interim_results = {} + if "spec" in response: + self._backend_requirements = response["spec"].get('backend_requirements', {}) + self._parameters = response["spec"].get('parameters', {}) + self._return_values = response["spec"].get('return_values', {}) + self._interim_results = response["spec"].get('interim_results', {}) + self._name = response['name'] + self._id = response['id'] + self._description = response.get('description', "") + self._max_execution_time = response.get('cost', 0) + self._creation_date = response.get('creation_date', "") + self._update_date = response.get('update_date', "") + self._is_public = response.get('is_public', False) + if 'data' in response: + self._data = response['data'] + else: + raise IBMQNotAuthorizedError( + 'Only program authors are authorized to retrieve program data') + return self._data + class ParameterNamespace(SimpleNamespace): """ A namespace for program parameters with validation. diff --git a/releasenotes/notes/program-data-retrieval-9a9782eb16274593.yaml b/releasenotes/notes/program-data-retrieval-9a9782eb16274593.yaml new file mode 100644 index 000000000..c464b58d0 --- /dev/null +++ b/releasenotes/notes/program-data-retrieval-9a9782eb16274593.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + If you are the author of a runtime program, + you can now use :attr:`qiskit.providers.ibmq.runtime.RuntimeProgram.data` + property to retrieve the program data as a string. diff --git a/test/ibmq/runtime/test_runtime.py b/test/ibmq/runtime/test_runtime.py index 6697d4379..71d90b113 100644 --- a/test/ibmq/runtime/test_runtime.py +++ b/test/ibmq/runtime/test_runtime.py @@ -66,6 +66,7 @@ class TestRuntime(IBMQTestCase): """Class for testing runtime modules.""" + DEFAULT_DATA = "def main() {}" DEFAULT_METADATA = { "name": "qiskit-test", "description": "Test program.", @@ -426,10 +427,18 @@ def test_run_program_with_custom_runtime_image(self): self.assertTrue(job.result()) self.assertEqual(job.image, image) + def test_retrieve_program_data(self): + """Test retrieving program data""" + program_id = self._upload_program(name="qiskit-test") + self.runtime.programs() + program = self.runtime.program(program_id) + self.assertEqual(program.data, self.DEFAULT_DATA) + self._validate_program(program) + def test_program_params_validation(self): """Test program parameters validation process""" program_id = self.runtime.upload_program( - data="def main() {}", metadata=self.DEFAULT_METADATA) + data=self.DEFAULT_DATA, metadata=self.DEFAULT_METADATA) program = self.runtime.program(program_id) params: ParameterNamespace = program.parameters() params.param1 = 'Hello, World' @@ -447,7 +456,7 @@ def test_program_params_validation(self): def test_program_params_namespace(self): """Test running a program using parameter namespace.""" program_id = self.runtime.upload_program( - data="def main() {}", metadata=self.DEFAULT_METADATA) + data=self.DEFAULT_DATA, metadata=self.DEFAULT_METADATA) params = self.runtime.program(program_id).parameters() params.param1 = "Hello World" self._run_program(program_id, inputs=params) @@ -685,23 +694,10 @@ def test_program_metadata(self): for metadata in sub_tests: with self.subTest(metadata_type=type(metadata)): - program_id = self.runtime.upload_program(data="def main() {}", metadata=metadata) + program_id = self.runtime.upload_program(data=self.DEFAULT_DATA, metadata=metadata) program = self.runtime.program(program_id) self.runtime.delete_program(program_id) - self.assertEqual(self.DEFAULT_METADATA['name'], program.name) - self.assertEqual(self.DEFAULT_METADATA['description'], program.description) - self.assertEqual(self.DEFAULT_METADATA['max_execution_time'], - program.max_execution_time) - self.assertTrue(program.creation_date) - self.assertTrue(program.update_date) - self.assertEqual(self.DEFAULT_METADATA['spec']['backend_requirements'], - program.backend_requirements) - self.assertEqual(self.DEFAULT_METADATA['spec']['parameters'], - program.parameters().metadata) - self.assertEqual(self.DEFAULT_METADATA['spec']['return_values'], - program.return_values) - self.assertEqual(self.DEFAULT_METADATA['spec']['interim_results'], - program.interim_results) + self._validate_program(program) def test_different_providers(self): """Test retrieving job submitted with different provider.""" @@ -718,7 +714,7 @@ def _upload_program(self, name=None, max_execution_time=300, is_public: bool = False): """Upload a new program.""" name = name or uuid.uuid4().hex - data = "def main() {}" + data = self.DEFAULT_DATA metadata = copy.deepcopy(self.DEFAULT_METADATA) metadata.update(name=name) metadata.update(is_public=is_public) @@ -762,3 +758,20 @@ def _populate_jobs_with_all_statuses(self, jobs, program_id): jobs.append(self._run_program(program_id, final_status='CANCELLED')) returned_jobs_count += 1 return (jobs, pending_jobs_count, returned_jobs_count) + + def _validate_program(self, program): + """Validate a program.""" + self.assertEqual(self.DEFAULT_METADATA['name'], program.name) + self.assertEqual(self.DEFAULT_METADATA['description'], program.description) + self.assertEqual(self.DEFAULT_METADATA['max_execution_time'], + program.max_execution_time) + self.assertTrue(program.creation_date) + self.assertTrue(program.update_date) + self.assertEqual(self.DEFAULT_METADATA['spec']['backend_requirements'], + program.backend_requirements) + self.assertEqual(self.DEFAULT_METADATA['spec']['parameters'], + program.parameters().metadata) + self.assertEqual(self.DEFAULT_METADATA['spec']['return_values'], + program.return_values) + self.assertEqual(self.DEFAULT_METADATA['spec']['interim_results'], + program.interim_results) diff --git a/test/ibmq/runtime/test_runtime_integration.py b/test/ibmq/runtime/test_runtime_integration.py index 8f60edc7f..0392b3838 100644 --- a/test/ibmq/runtime/test_runtime_integration.py +++ b/test/ibmq/runtime/test_runtime_integration.py @@ -146,6 +146,19 @@ def test_list_program(self): self.assertEqual(self.program_id, program.program_id) self._validate_program(program) + def test_retrieve_program_data(self): + """Test retrieving program data""" + program = self.provider.runtime.program(self.program_id) + self.assertEqual(self.RUNTIME_PROGRAM, program.data) + self._validate_program(program) + + def test_retrieve_unauthorized_program_data(self): + """Test retrieving program data when user is not the program author""" + program = self.provider.runtime.program('sample-program') + self._validate_program(program) + with self.assertRaises(IBMQNotAuthorizedError): + return program.data + def test_upload_program(self): """Test uploading a program.""" max_execution_time = 3000 From 5580852be13bac8797cc263fe570d8cc8fc81eac Mon Sep 17 00:00:00 2001 From: Rathish Cholarajan Date: Thu, 4 Nov 2021 22:09:25 -0400 Subject: [PATCH 6/8] Update cache after updating program (#196) (#1085) --- .../ibmq/runtime/ibm_runtime_service.py | 8 +++ .../providers/ibmq/runtime/runtime_program.py | 55 ++++++++++++------- test/ibmq/runtime/test_runtime_integration.py | 7 +-- 3 files changed, 45 insertions(+), 25 deletions(-) diff --git a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py index 78822a353..40528035a 100644 --- a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py +++ b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py @@ -431,6 +431,10 @@ def update_program( raise RuntimeProgramNotFound(f"Program not found: {ex.message}") from None raise QiskitRuntimeError(f"Failed to update program: {ex}") from None + if program_id in self._programs: + program = self._programs[program_id] + program._refresh() + def _merge_metadata( self, metadata: Optional[Dict] = None, @@ -492,6 +496,10 @@ def set_program_visibility(self, program_id: str, public: bool) -> None: raise RuntimeJobNotFound(f"Program not found: {ex.message}") from None raise QiskitRuntimeError(f"Failed to set program visibility: {ex}") from None + if program_id in self._programs: + program = self._programs[program_id] + program._is_public = public + def job(self, job_id: str) -> RuntimeJob: """Retrieve a runtime job. diff --git a/qiskit/providers/ibmq/runtime/runtime_program.py b/qiskit/providers/ibmq/runtime/runtime_program.py index 786a86a44..8d9ea2bce 100644 --- a/qiskit/providers/ibmq/runtime/runtime_program.py +++ b/qiskit/providers/ibmq/runtime/runtime_program.py @@ -17,7 +17,9 @@ from typing import Optional, List, Dict from types import SimpleNamespace from qiskit.providers.ibmq.exceptions import IBMQInputValueError, IBMQNotAuthorizedError +from .exceptions import QiskitRuntimeError, RuntimeProgramNotFound from ..api.clients.runtime import RuntimeClient +from ..api.exceptions import RequestsApiError logger = logging.getLogger(__name__) @@ -275,30 +277,43 @@ def data(self) -> str: IBMQNotAuthorizedError: if user is not the program author. """ if not self._data: - response = self._api_client.program_get(self._id) - self._backend_requirements = {} - self._parameters = {} - self._return_values = {} - self._interim_results = {} - if "spec" in response: - self._backend_requirements = response["spec"].get('backend_requirements', {}) - self._parameters = response["spec"].get('parameters', {}) - self._return_values = response["spec"].get('return_values', {}) - self._interim_results = response["spec"].get('interim_results', {}) - self._name = response['name'] - self._id = response['id'] - self._description = response.get('description', "") - self._max_execution_time = response.get('cost', 0) - self._creation_date = response.get('creation_date', "") - self._update_date = response.get('update_date', "") - self._is_public = response.get('is_public', False) - if 'data' in response: - self._data = response['data'] - else: + self._refresh() + if not self._data: raise IBMQNotAuthorizedError( 'Only program authors are authorized to retrieve program data') return self._data + def _refresh(self) -> None: + """Refresh program data and metadata + + Raises: + RuntimeProgramNotFound: If the program does not exist. + QiskitRuntimeError: If the request failed. + """ + try: + response = self._api_client.program_get(self._id) + except RequestsApiError as ex: + if ex.status_code == 404: + raise RuntimeProgramNotFound(f"Program not found: {ex.message}") from None + raise QiskitRuntimeError(f"Failed to get program: {ex}") from None + self._backend_requirements = {} + self._parameters = {} + self._return_values = {} + self._interim_results = {} + if "spec" in response: + self._backend_requirements = response["spec"].get('backend_requirements', {}) + self._parameters = response["spec"].get('parameters', {}) + self._return_values = response["spec"].get('return_values', {}) + self._interim_results = response["spec"].get('interim_results', {}) + self._name = response['name'] + self._id = response['id'] + self._description = response.get('description', "") + self._max_execution_time = response.get('cost', 0) + self._creation_date = response.get('creation_date', "") + self._update_date = response.get('update_date', "") + self._is_public = response.get('is_public', False) + self._data = response.get('data', "") + class ParameterNamespace(SimpleNamespace): """ A namespace for program parameters with validation. diff --git a/test/ibmq/runtime/test_runtime_integration.py b/test/ibmq/runtime/test_runtime_integration.py index 0392b3838..3ba040549 100644 --- a/test/ibmq/runtime/test_runtime_integration.py +++ b/test/ibmq/runtime/test_runtime_integration.py @@ -238,13 +238,10 @@ def main(backend, user_messenger, **kwargs): def main(backend, user_messenger, **kwargs): return "version 2" """ - # TODO retrieve program data instead of run program when #66 is merged program_id = self._upload_program(data=program_v1) - job = self._run_program(program_id=program_id) - self.assertEqual("version 1", job.result()) + self.assertEqual(program_v1, self.provider.runtime.program(program_id).data) self.provider.runtime.update_program(program_id=program_id, data=program_v2) - job = self._run_program(program_id=program_id) - self.assertEqual("version 2", job.result()) + self.assertEqual(program_v2, self.provider.runtime.program(program_id).data) def test_update_program_metadata(self): """Test updating program metadata.""" From d7c582f60fcb5eea2f82b76af67778c8d08b825b Mon Sep 17 00:00:00 2001 From: Rathish Cholarajan Date: Sun, 7 Nov 2021 21:11:17 -0500 Subject: [PATCH 7/8] Allow filtering runtime jobs by provider --- qiskit/providers/ibmq/api/clients/runtime.py | 11 ++++++-- qiskit/providers/ibmq/api/rest/runtime.py | 10 ++++++- .../ibmq/runtime/ibm_runtime_service.py | 22 ++++++++++++++-- ...ter-jobs-by-provider-dead04faaf223840.yaml | 5 ++++ test/ibmq/runtime/fake_runtime_client.py | 26 ++++++++++++++++--- test/ibmq/runtime/test_runtime.py | 20 +++++++++++++- test/ibmq/runtime/test_runtime_integration.py | 16 ++++++++++++ 7 files changed, 100 insertions(+), 10 deletions(-) create mode 100644 releasenotes/notes/filter-jobs-by-provider-dead04faaf223840.yaml diff --git a/qiskit/providers/ibmq/api/clients/runtime.py b/qiskit/providers/ibmq/api/clients/runtime.py index bb27ea150..be5fef4c1 100644 --- a/qiskit/providers/ibmq/api/clients/runtime.py +++ b/qiskit/providers/ibmq/api/clients/runtime.py @@ -179,7 +179,10 @@ def jobs_get( limit: int = None, skip: int = None, pending: bool = None, - program_id: str = None + program_id: str = None, + hub: str = None, + group: str = None, + project: str = None ) -> Dict: """Get job data for all jobs. @@ -189,11 +192,15 @@ def jobs_get( pending: Returns 'QUEUED' and 'RUNNING' jobs if True, returns 'DONE', 'CANCELLED' and 'ERROR' jobs if False. program_id: Filter by Program ID. + hub: Filter by hub - hub, group, and project must all be specified. + group: Filter by group - hub, group, and project must all be specified. + project: Filter by project - hub, group, and project must all be specified. Returns: JSON response. """ - return self.api.jobs_get(limit=limit, skip=skip, pending=pending, program_id=program_id) + return self.api.jobs_get(limit=limit, skip=skip, pending=pending, + program_id=program_id, hub=hub, group=group, project=project) def job_results(self, job_id: str) -> str: """Get the results of a program job. diff --git a/qiskit/providers/ibmq/api/rest/runtime.py b/qiskit/providers/ibmq/api/rest/runtime.py index 935538883..0f4754fd6 100644 --- a/qiskit/providers/ibmq/api/rest/runtime.py +++ b/qiskit/providers/ibmq/api/rest/runtime.py @@ -139,7 +139,10 @@ def jobs_get( limit: int = None, skip: int = None, pending: bool = None, - program_id: str = None + program_id: str = None, + hub: str = None, + group: str = None, + project: str = None ) -> Dict: """Get a list of job data. @@ -149,6 +152,9 @@ def jobs_get( pending: Returns 'QUEUED' and 'RUNNING' jobs if True, returns 'DONE', 'CANCELLED' and 'ERROR' jobs if False. program_id: Filter by Program ID. + hub: Filter by hub - hub, group, and project must all be specified. + group: Filter by group - hub, group, and project must all be specified. + project: Filter by project - hub, group, and project must all be specified. Returns: JSON response. @@ -163,6 +169,8 @@ def jobs_get( payload['pending'] = 'true' if pending else 'false' if program_id: payload['program'] = program_id + if all([hub, group, project]): + payload['provider'] = f"{hub}/{group}/{project}" return self.session.get(url, params=payload).json() def logout(self) -> None: diff --git a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py index 40528035a..4ea24faba 100644 --- a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py +++ b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py @@ -526,7 +526,10 @@ def jobs( limit: Optional[int] = 10, skip: int = 0, pending: bool = None, - program_id: str = None + program_id: str = None, + hub: str = None, + group: str = None, + project: str = None ) -> List[RuntimeJob]: """Retrieve all runtime jobs, subject to optional filtering. @@ -537,10 +540,22 @@ def jobs( jobs are included. If ``False``, 'DONE', 'CANCELLED' and 'ERROR' jobs are included. program_id: Filter by Program ID. + hub: Filter by hub - hub, group, and project must all be specified. + group: Filter by group - hub, group, and project must all be specified. + project: Filter by project - hub, group, and project must all be specified. Returns: A list of runtime jobs. + + Raises: + IBMQInputValueError: If any but not all of the parameters ``hub``, ``group`` + and ``project`` are given. """ + if any([hub, group, project]) and not all([hub, group, project]): + raise IBMQInputValueError('Hub, group and project ' + 'parameters must all be specified. ' + 'hub = "{}", group = "{}", project = "{}"' + .format(hub, group, project)) job_responses = [] # type: List[Dict[str, Any]] current_page_limit = limit or 20 offset = skip @@ -550,7 +565,10 @@ def jobs( limit=current_page_limit, skip=offset, pending=pending, - program_id=program_id) + program_id=program_id, + hub=hub, + group=group, + project=project) job_page = jobs_response["jobs"] # count is the total number of jobs that would be returned if # there was no limit or skip diff --git a/releasenotes/notes/filter-jobs-by-provider-dead04faaf223840.yaml b/releasenotes/notes/filter-jobs-by-provider-dead04faaf223840.yaml new file mode 100644 index 000000000..e8ad382e3 --- /dev/null +++ b/releasenotes/notes/filter-jobs-by-provider-dead04faaf223840.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + You can now pass ``hub``, ``group``, and ``project`` parameters to + :meth:`qiskit.providers.ibmq.runtime.IBMRuntimeService.jobs` to filter jobs. \ No newline at end of file diff --git a/test/ibmq/runtime/fake_runtime_client.py b/test/ibmq/runtime/fake_runtime_client.py index dbb7be5f6..c8be91940 100644 --- a/test/ibmq/runtime/fake_runtime_client.py +++ b/test/ibmq/runtime/fake_runtime_client.py @@ -214,13 +214,23 @@ def _auto_progress(self): class BaseFakeRuntimeClient: """Base class for faking the runtime client.""" - def __init__(self, job_classes=None, final_status=None, job_kwargs=None): + def __init__(self, job_classes=None, final_status=None, job_kwargs=None, + hub=None, group=None, project=None): """Initialize a fake runtime client.""" self._programs = {} self._jobs = {} self._job_classes = job_classes or [] self._final_status = final_status self._job_kwargs = job_kwargs or {} + self._hub = hub + self._group = group + self._project = project + + def set_hgp(self, hub, group, project): + """Set hub, group and project""" + self._hub = hub + self._group = group + self._project = project def set_job_classes(self, classes): """Set job classes to use.""" @@ -297,9 +307,12 @@ def program_run( """Run the specified program.""" job_id = uuid.uuid4().hex job_cls = self._job_classes.pop(0) if len(self._job_classes) > 0 else BaseFakeRuntimeJob + hub = self._hub or credentials.hub + group = self._group or credentials.group + project = self._project or credentials.project job = job_cls(job_id=job_id, program_id=program_id, - hub=credentials.hub, group=credentials.group, - project=credentials.project, backend_name=backend_name, + hub=hub, group=group, + project=project, backend_name=backend_name, params=params, final_status=self._final_status, image=image, **self._job_kwargs) self._jobs[job_id] = job @@ -315,7 +328,8 @@ def job_get(self, job_id): """Get the specific job.""" return self._get_job(job_id).to_dict() - def jobs_get(self, limit=None, skip=None, pending=None, program_id=None): + def jobs_get(self, limit=None, skip=None, pending=None, program_id=None, + hub=None, group=None, project=None): """Get all jobs.""" pending_statuses = ['QUEUED', 'RUNNING'] returned_statuses = ['COMPLETED', 'FAILED', 'CANCELLED'] @@ -330,6 +344,10 @@ def jobs_get(self, limit=None, skip=None, pending=None, program_id=None): if program_id: jobs = [job for job in jobs if job._program_id == program_id] count = len(jobs) + if all([hub, group, project]): + jobs = [job for job in jobs if + job._hub == hub and job._group == group and job._project == project] + count = len(jobs) jobs = jobs[skip:limit+skip] return {"jobs": [job.to_dict() for job in jobs], "count": count} diff --git a/test/ibmq/runtime/test_runtime.py b/test/ibmq/runtime/test_runtime.py index 71d90b113..e858701bb 100644 --- a/test/ibmq/runtime/test_runtime.py +++ b/test/ibmq/runtime/test_runtime.py @@ -621,6 +621,22 @@ def test_jobs_filter_by_program_id(self): self.assertEqual(program_id, rjobs[0].program_id) self.assertEqual(1, len(rjobs)) + def test_jobs_filter_by_provider(self): + """Test retrieving jobs by provider.""" + program_id = self._upload_program() + job = self._run_program(program_id=program_id, + hub="defaultHub", group="defaultGroup", project="defaultProject") + job.wait_for_final_state() + rjobs = self.runtime.jobs(program_id=program_id, + hub="defaultHub", group="defaultGroup", project="defaultProject") + self.assertEqual(program_id, rjobs[0].program_id) + self.assertEqual(1, len(rjobs)) + rjobs = self.runtime.jobs(program_id=program_id, + hub="test", group="test", project="test") + self.assertFalse(rjobs) + with self.assertRaises(IBMQInputValueError): + self.runtime.jobs(hub="defaultHub") + def test_cancel_job(self): """Test canceling a job.""" job = self._run_program(job_classes=CancelableRuntimeJob) @@ -725,13 +741,15 @@ 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="", hub=None, group=None, project=None): """Run a program.""" options = {'backend_name': "some_backend"} if final_status is not None: self.runtime._api_client.set_final_status(final_status) elif job_classes: self.runtime._api_client.set_job_classes(job_classes) + elif all([hub, group, project]): + self.runtime._api_client.set_hgp(hub, group, project) if program_id is None: program_id = self._upload_program() job = self.runtime.run(program_id=program_id, inputs=inputs, diff --git a/test/ibmq/runtime/test_runtime_integration.py b/test/ibmq/runtime/test_runtime_integration.py index 3ba040549..215da0ed9 100644 --- a/test/ibmq/runtime/test_runtime_integration.py +++ b/test/ibmq/runtime/test_runtime_integration.py @@ -394,6 +394,22 @@ def test_retrieve_jobs_by_program_id(self): self.assertEqual(program_id, rjobs[0].program_id) self.assertEqual(1, len(rjobs)) + def test_jobs_filter_by_provider(self): + """Test retrieving jobs by provider.""" + hub = self.provider.credentials.hub + group = self.provider.credentials.group + project = self.provider.credentials.project + program_id = self._upload_program() + job = self._run_program(program_id=program_id) + job.wait_for_final_state() + rjobs = self.provider.runtime.jobs(program_id=program_id, + hub=hub, group=group, project=project) + self.assertEqual(program_id, rjobs[0].program_id) + self.assertEqual(1, len(rjobs)) + rjobs = self.provider.runtime.jobs(program_id=program_id, + hub="test", group="test", project="test") + self.assertFalse(rjobs) + def test_cancel_job_queued(self): """Test canceling a queued job.""" _ = self._run_program(iterations=10) From 582ba052bc704cb15387b04d230a657d87ff8ae1 Mon Sep 17 00:00:00 2001 From: Rathish Cholarajan Date: Mon, 8 Nov 2021 07:44:37 -0500 Subject: [PATCH 8/8] Support pagination for retrieving runtime programs (#1087) --- qiskit/providers/ibmq/api/clients/runtime.py | 10 +++-- qiskit/providers/ibmq/api/rest/runtime.py | 15 ++++++-- .../ibmq/runtime/ibm_runtime_service.py | 38 +++++++++++++++---- ...e-program-pagination-8d599ae984a5ce33.yaml | 9 +++++ test/ibmq/runtime/fake_runtime_client.py | 4 +- test/ibmq/runtime/test_runtime.py | 14 +++++++ test/ibmq/runtime/test_runtime_integration.py | 15 ++++++++ 7 files changed, 89 insertions(+), 16 deletions(-) create mode 100644 releasenotes/notes/runtime-program-pagination-8d599ae984a5ce33.yaml diff --git a/qiskit/providers/ibmq/api/clients/runtime.py b/qiskit/providers/ibmq/api/clients/runtime.py index bb27ea150..86940ad7a 100644 --- a/qiskit/providers/ibmq/api/clients/runtime.py +++ b/qiskit/providers/ibmq/api/clients/runtime.py @@ -39,13 +39,17 @@ def __init__( **credentials.connection_parameters()) self.api = Runtime(self._session) - def list_programs(self) -> Dict[str, Any]: + def list_programs(self, limit: int = None, skip: int = None) -> Dict[str, Any]: """Return a list of runtime programs. + Args: + limit: The number of programs to return. + skip: The number of programs to skip. + Returns: - A list of quantum programs. + A list of runtime programs. """ - return self.api.list_programs() + return self.api.list_programs(limit, skip) def program_create( self, diff --git a/qiskit/providers/ibmq/api/rest/runtime.py b/qiskit/providers/ibmq/api/rest/runtime.py index 935538883..a55343bd2 100644 --- a/qiskit/providers/ibmq/api/rest/runtime.py +++ b/qiskit/providers/ibmq/api/rest/runtime.py @@ -55,14 +55,23 @@ def program_job(self, job_id: str) -> 'ProgramJob': """ return ProgramJob(self.session, job_id) - def list_programs(self) -> Dict[str, Any]: + def list_programs(self, limit: int = None, skip: int = None) -> Dict[str, Any]: """Return a list of runtime programs. + Args: + limit: The number of programs to return. + skip: The number of programs to skip. + Returns: - JSON response. + A list of runtime programs. """ url = self.get_url('programs') - return self.session.get(url).json() + payload: Dict[str, int] = {} + if limit: + payload['limit'] = limit + if skip: + payload['offset'] = skip + return self.session.get(url, params=payload).json() def create_program( self, diff --git a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py index 40528035a..7ccf9fccd 100644 --- a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py +++ b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py @@ -107,15 +107,19 @@ def __init__(self, provider: 'accountprovider.AccountProvider') -> None: self._ws_url = provider.credentials.runtime_url.replace('https', 'wss') self._programs = {} # type: Dict - def pprint_programs(self, refresh: bool = False, detailed: bool = False) -> None: + def pprint_programs(self, refresh: bool = False, detailed: bool = False, + limit: int = 20, skip: int = 0) -> None: """Pretty print information about available runtime programs. Args: refresh: If ``True``, re-query the server for the programs. Otherwise return the cached value. detailed: If ``True`` print all details about available runtime programs. + limit: The number of programs returned at a time. Default and maximum + value of 20. + skip: The number of programs to skip. """ - programs = self.programs(refresh) + programs = self.programs(refresh, limit, skip) for prog in programs: print("="*50) if detailed: @@ -125,7 +129,8 @@ def pprint_programs(self, refresh: bool = False, detailed: bool = False) -> None print(f" Name: {prog.name}") print(f" Description: {prog.description}") - def programs(self, refresh: bool = False) -> List[RuntimeProgram]: + def programs(self, refresh: bool = False, + limit: int = 20, skip: int = 0) -> List[RuntimeProgram]: """Return available runtime programs. Currently only program metadata is returned. @@ -133,17 +138,34 @@ def programs(self, refresh: bool = False) -> List[RuntimeProgram]: Args: refresh: If ``True``, re-query the server for the programs. Otherwise return the cached value. + limit: The number of programs returned at a time. ``None`` means no limit. + skip: The number of programs to skip. Returns: A list of runtime programs. """ + if skip is None: + skip = 0 if not self._programs or refresh: self._programs = {} - response = self._api_client.list_programs() - for prog_dict in response.get("programs", []): - program = self._to_program(prog_dict) - self._programs[program.program_id] = program - return list(self._programs.values()) + current_page_limit = 20 + offset = 0 + while True: + response = self._api_client.list_programs(limit=current_page_limit, skip=offset) + program_page = response.get("programs", []) + # count is the total number of programs that would be returned if + # there was no limit or skip + count = response.get("count", 0) + for prog_dict in program_page: + program = self._to_program(prog_dict) + self._programs[program.program_id] = program + if len(self._programs) == count: + # Stop if there are no more programs returned by the server. + break + offset += len(program_page) + if limit is None: + limit = len(self._programs) + return list(self._programs.values())[skip:limit+skip] def program(self, program_id: str, refresh: bool = False) -> RuntimeProgram: """Retrieve a runtime program. diff --git a/releasenotes/notes/runtime-program-pagination-8d599ae984a5ce33.yaml b/releasenotes/notes/runtime-program-pagination-8d599ae984a5ce33.yaml new file mode 100644 index 000000000..de520070e --- /dev/null +++ b/releasenotes/notes/runtime-program-pagination-8d599ae984a5ce33.yaml @@ -0,0 +1,9 @@ +--- +upgrade: + - | + ``limit`` and ``skip`` parameters have been added to + :meth:`qiskit.providers.ibmq.runtime.IBMRuntimeService.programs` and + :meth:`qiskit.providers.ibmq.runtime.IBMRuntimeService.pprint_programs`. + ``limit`` can be used to set the number of runtime programs returned + and ``skip`` is the number of programs to skip when retrieving + programs. diff --git a/test/ibmq/runtime/fake_runtime_client.py b/test/ibmq/runtime/fake_runtime_client.py index dbb7be5f6..e333b842e 100644 --- a/test/ibmq/runtime/fake_runtime_client.py +++ b/test/ibmq/runtime/fake_runtime_client.py @@ -232,12 +232,12 @@ def set_final_status(self, final_status): """Set job status to passed in final status instantly.""" self._final_status = final_status - def list_programs(self): + def list_programs(self, limit, skip): """List all programs.""" programs = [] for prog in self._programs.values(): programs.append(prog.to_dict()) - return {"programs": programs} + return {"programs": programs[skip:limit+skip], "count": len(self._programs)} def program_create(self, program_data, name, description, max_execution_time, spec=None, is_public=False): diff --git a/test/ibmq/runtime/test_runtime.py b/test/ibmq/runtime/test_runtime.py index 71d90b113..e6d5198aa 100644 --- a/test/ibmq/runtime/test_runtime.py +++ b/test/ibmq/runtime/test_runtime.py @@ -301,6 +301,20 @@ def test_list_programs(self): all_ids = [prog.program_id for prog in programs] self.assertIn(program_id, all_ids) + def test_list_programs_with_limit_skip(self): + """Test listing programs with limit and skip.""" + program_1 = self._upload_program() + program_2 = self._upload_program() + program_3 = self._upload_program() + programs = self.runtime.programs(limit=2, skip=1) + all_ids = [prog.program_id for prog in programs] + self.assertNotIn(program_1, all_ids) + self.assertIn(program_2, all_ids) + self.assertIn(program_3, all_ids) + programs = self.runtime.programs(limit=3) + all_ids = [prog.program_id for prog in programs] + self.assertIn(program_1, all_ids) + def test_list_program(self): """Test listing a single program.""" program_id = self._upload_program() diff --git a/test/ibmq/runtime/test_runtime_integration.py b/test/ibmq/runtime/test_runtime_integration.py index 3ba040549..cbf7af23d 100644 --- a/test/ibmq/runtime/test_runtime_integration.py +++ b/test/ibmq/runtime/test_runtime_integration.py @@ -140,6 +140,21 @@ def test_list_programs(self): found = True self.assertTrue(found, f"Program {self.program_id} not found!") + def test_list_programs_with_limit_skip(self): + """Test listing programs with limit and skip.""" + self._upload_program() + self._upload_program() + self._upload_program() + programs = self.provider.runtime.programs(limit=3) + all_ids = [prog.program_id for prog in programs] + self.assertEqual(len(all_ids), 3) + programs = self.provider.runtime.programs(limit=2, skip=1) + some_ids = [prog.program_id for prog in programs] + self.assertEqual(len(some_ids), 2) + self.assertNotIn(all_ids[0], some_ids) + self.assertIn(all_ids[1], some_ids) + self.assertIn(all_ids[2], some_ids) + def test_list_program(self): """Test listing a single program.""" program = self.provider.runtime.program(self.program_id)