diff --git a/qiskit_ibm/api/clients/runtime.py b/qiskit_ibm/api/clients/runtime.py index 4156c4999..5cc4df6b5 100644 --- a/qiskit_ibm/api/clients/runtime.py +++ b/qiskit_ibm/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_ibm.credentials import Credentials from qiskit_ibm.api.session import RetrySession @@ -39,40 +39,36 @@ def __init__( **credentials.connection_parameters()) self.api = Runtime(self._session) - def list_programs(self) -> List[Dict]: + 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, - 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 +77,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: @@ -97,17 +91,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. @@ -127,7 +110,7 @@ def program_run( program_id: str, credentials: Credentials, backend_name: str, - params: str, + params: Dict, image: str ) -> Dict: """Run the specified program. @@ -155,14 +138,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. + 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. @@ -177,7 +178,16 @@ 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, + hub: str = None, + group: str = None, + project: str = None + ) -> Dict: """Get job data for all jobs. Args: @@ -185,11 +195,16 @@ 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. + 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) + 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_ibm/api/rest/runtime.py b/qiskit_ibm/api/rest/runtime.py index e8252ee0b..e44265c96 100644 --- a/qiskit_ibm/api/rest/runtime.py +++ b/qiskit_ibm/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,65 +55,56 @@ def program_job(self, job_id: str) -> 'ProgramJob': """ return ProgramJob(self.session, job_id) - def list_programs(self) -> List[Dict]: + 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, - 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 +113,7 @@ def program_run( group: str, project: str, backend_name: str, - params: str, + params: Dict, image: str ) -> Dict: """Execute the program. @@ -140,18 +132,27 @@ 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: + def jobs_get( + self, + limit: int = None, + skip: int = None, + pending: bool = None, + program_id: str = None, + hub: str = None, + group: str = None, + project: str = None + ) -> Dict: """Get a list of job data. Args: @@ -159,6 +160,10 @@ 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. + 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. @@ -171,6 +176,10 @@ 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 + if all([hub, group, project]): + payload['provider'] = f"{hub}/{group}/{project}" return self.session.get(url, params=payload).json() def logout(self) -> None: @@ -211,15 +220,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') @@ -239,15 +239,43 @@ 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. + 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'}) + + 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): diff --git a/qiskit_ibm/runtime/__init__.py b/qiskit_ibm/runtime/__init__.py index 83012f6e2..7b8fc84aa 100644 --- a/qiskit_ibm/runtime/__init__.py +++ b/qiskit_ibm/runtime/__init__.py @@ -191,14 +191,11 @@ def interim_result_callback(job_id, interim_result): provider = IBMProvider() 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_ibm/runtime/ibm_runtime_service.py b/qiskit_ibm/runtime/ibm_runtime_service.py index 5d10f1d0e..a131b4298 100644 --- a/qiskit_ibm/runtime/ibm_runtime_service.py +++ b/qiskit_ibm/runtime/ibm_runtime_service.py @@ -15,15 +15,15 @@ import logging from typing import Dict, Callable, Optional, Union, List, Any, Type import json -import copy import re +import warnings from qiskit.providers.exceptions import QiskitBackendNotFoundError from qiskit_ibm import ibm_provider # 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 @@ -107,24 +107,30 @@ def __init__(self, provider: 'ibm_provider.IBMProvider') -> 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: 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]: + def programs(self, refresh: bool = False, + limit: int = 20, skip: int = 0) -> List[RuntimeProgram]: """Return available runtime programs. Currently only program metadata is returned. @@ -132,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: - 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. @@ -182,22 +205,29 @@ 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), + data=response.get('data', ""), + api_client=self._api_client) def run( self, @@ -246,12 +276,11 @@ def run( raise IBMInputValueError('"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 +296,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 +319,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 +346,7 @@ def upload_program( IBMNotAuthorizedError: 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 +358,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 +371,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,50 +389,95 @@ 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, 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() - self._api_client.program_update(program_id, data) + 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: + 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 + + if program_id in self._programs: + program = self._programs[program_id] + program._refresh() + + 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. @@ -458,6 +518,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. @@ -483,7 +547,11 @@ def jobs( self, limit: Optional[int] = 10, skip: int = 0, - pending: bool = None + pending: bool = None, + program_id: str = None, + hub: str = None, + group: str = None, + project: str = None ) -> List[RuntimeJob]: """Retrieve all runtime jobs, subject to optional filtering. @@ -493,23 +561,47 @@ 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. + 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: + IBMInputValueError: 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 IBMInputValueError('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 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, + 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 + 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. @@ -518,7 +610,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/qiskit_ibm/runtime/program/program_metadata_sample.json b/qiskit_ibm/runtime/program/program_metadata_sample.json index a38c18fd6..6e9ae5000 100644 --- a/qiskit_ibm/runtime/program/program_metadata_sample.json +++ b/qiskit_ibm/runtime/program/program_metadata_sample.json @@ -2,16 +2,36 @@ "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"} - ] -} \ No newline at end of file + "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" + } + } + } + } +} diff --git a/qiskit_ibm/runtime/runtime_program.py b/qiskit_ibm/runtime/runtime_program.py index ee884146b..7cd1b6a2e 100644 --- a/qiskit_ibm/runtime/runtime_program.py +++ b/qiskit_ibm/runtime/runtime_program.py @@ -13,10 +13,13 @@ """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_ibm.exceptions import IBMInputValueError - +from qiskit_ibm.exceptions import IBMInputValueError, IBMNotAuthorizedError +from .exceptions import QiskitRuntimeError, RuntimeProgramNotFound +from ..api.clients.runtime import RuntimeClient +from ..api.exceptions import RequestsApiError logger = logging.getLogger(__name__) @@ -47,14 +50,16 @@ 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 = "", - is_public: Optional[bool] = False + update_date: str = "", + is_public: Optional[bool] = False, + data: str = "", + api_client: Optional[RuntimeClient] = None ) -> None: """RuntimeProgram constructor. @@ -66,59 +71,58 @@ 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. + data: Program data. + api_client: Runtime api client. """ 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'])) + self._data = data + self._api_client = api_client 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 +152,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 +202,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 +211,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 +230,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 +248,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. @@ -263,20 +266,53 @@ def is_public(self) -> bool: """ return self._is_public + @property + def data(self) -> str: + """Program data. -class ProgramParameter(NamedTuple): - """Program parameter.""" - name: str - description: str - type: str - required: bool + Returns: + Program data. + Raises: + IBMNotAuthorizedError: if user is not the program author. + """ + if not self._data: + self._refresh() + if not self._data: + raise IBMNotAuthorizedError( + 'Only program authors are authorized to retrieve program data') + return self._data + + def _refresh(self) -> None: + """Refresh program data and metadata -class ProgramResult(NamedTuple): - """Program result.""" - name: str - description: str - type: str + 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): @@ -286,26 +322,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 +357,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 IBMInputValueError('Param (%s) missing required value!' % param_name) + if value is None and parameter_name in self.metadata.get("required", []): + raise IBMInputValueError('Param (%s) missing required value!' % parameter_name) def __str__(self) -> str: """Creates string representation of object""" @@ -339,15 +375,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_ibm/runtime/utils.py b/qiskit_ibm/runtime/utils.py index 5b46f00ca..b0e4c2a57 100644 --- a/qiskit_ibm/runtime/utils.py +++ b/qiskit_ibm/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-filter-jobs-by-program-id-e7ef435bed1081be.yaml b/releasenotes/notes/feature-filter-jobs-by-program-id-e7ef435bed1081be.yaml new file mode 100644 index 000000000..bac90a5f2 --- /dev/null +++ b/releasenotes/notes/feature-filter-jobs-by-program-id-e7ef435bed1081be.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + You can now pass ``program_id`` parameter to :meth:`qiskit_ibm.runtime.IBMRuntimeService.jobs` + method to filter jobs by Program ID. 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..6fe46c1df --- /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_ibm.runtime.RuntimeProgram.update_date` property. 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..61e28343b --- /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_ibm.runtime.IBMRuntimeService.jobs` to filter jobs. \ No newline at end of file diff --git a/releasenotes/notes/program-data-retrieval-9a9782eb16274593.yaml b/releasenotes/notes/program-data-retrieval-9a9782eb16274593.yaml new file mode 100644 index 000000000..e1daea8fa --- /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_ibm.runtime.RuntimeProgram.data` + property to retrieve the program data as a string. 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/runtime-program-pagination-8d599ae984a5ce33.yaml b/releasenotes/notes/runtime-program-pagination-8d599ae984a5ce33.yaml new file mode 100644 index 000000000..2e3d3644d --- /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_ibm.runtime.IBMRuntimeService.programs` and + :meth:`qiskit_ibm.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/releasenotes/notes/update-runtime-metadata-d2ddbcfc0d034530.yaml b/releasenotes/notes/update-runtime-metadata-d2ddbcfc0d034530.yaml new file mode 100644 index 000000000..18ff3d661 --- /dev/null +++ b/releasenotes/notes/update-runtime-metadata-d2ddbcfc0d034530.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + You can now use the :meth:`qiskit_ibm.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/releasenotes/notes/upgrade-metadata-json-schema-46f034ada7443cf9.yaml b/releasenotes/notes/upgrade-metadata-json-schema-46f034ada7443cf9.yaml new file mode 100644 index 000000000..08eb17d93 --- /dev/null +++ b/releasenotes/notes/upgrade-metadata-json-schema-46f034ada7443cf9.yaml @@ -0,0 +1,10 @@ +--- +upgrade: + - | + :meth:`qiskit_ibm.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/ibm/runtime/fake_runtime_client.py b/test/ibm/runtime/fake_runtime_client.py index 6711d5ee7..a8b6643fd 100644 --- a/test/ibm/runtime/fake_runtime_client.py +++ b/test/ibm/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_ibm.credentials import Credentials @@ -26,7 +27,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 +36,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 +48,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['data'] = base64.standard_b64decode(self._data).decode() + 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 @@ -212,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.""" @@ -230,39 +242,59 @@ 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): - """List all progrmas.""" + def list_programs(self, limit, skip): + """List all programs.""" programs = [] for prog in self._programs.values(): programs.append(prog.to_dict()) - return programs + return {"programs": programs[skip:limit+skip], "count": len(self._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} + 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, @@ -275,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 @@ -293,19 +328,29 @@ 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, + hub=None, group=None, project=None): """Get all jobs.""" pending_statuses = ['QUEUED', 'RUNNING'] returned_statuses = ['COMPLETED', 'FAILED', 'CANCELLED'] 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) + 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": len(self._jobs)} + "count": count} def set_program_visibility(self, program_id: str, public: bool) -> None: """Sets a program's visibility. diff --git a/test/ibm/runtime/test_runtime.py b/test/ibm/runtime/test_runtime.py index f60f027fb..1964ddbd1 100644 --- a/test/ibm/runtime/test_runtime.py +++ b/test/ibm/runtime/test_runtime.py @@ -12,6 +12,7 @@ """Tests for runtime service.""" +import copy import json import os from io import StringIO @@ -55,7 +56,7 @@ from qiskit_ibm.runtime import IBMRuntimeService, RuntimeJob from qiskit_ibm.runtime.constants import API_TO_JOB_ERROR_MESSAGE from qiskit_ibm.runtime.exceptions import RuntimeProgramNotFound, RuntimeJobFailureError -from qiskit_ibm.runtime.runtime_program import ParameterNamespace, ProgramParameter, ProgramResult +from qiskit_ibm.runtime.runtime_program import ParameterNamespace from ...ibm_test_case import IBMTestCase from .fake_runtime_client import (BaseFakeRuntimeClient, FailedRanTooLongRuntimeJob, @@ -66,21 +67,55 @@ class TestRuntime(IBMTestCase): """Class for testing runtime modules.""" + DEFAULT_DATA = "def main() {}" DEFAULT_METADATA = { "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): @@ -267,6 +302,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() @@ -286,13 +335,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.""" @@ -306,6 +355,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() @@ -346,10 +442,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' @@ -367,7 +471,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) @@ -520,6 +624,34 @@ 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_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(IBMInputValueError): + self.runtime.jobs(hub="defaultHub") + def test_cancel_job(self): """Test canceling a job.""" job = self._run_program(job_classes=CancelableRuntimeJob) @@ -593,34 +725,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.assertEqual(self.DEFAULT_METADATA["version"], program.version) - self.assertEqual(self.DEFAULT_METADATA['backend_requirements'], - program.backend_requirements) - self.assertEqual([ProgramParameter(**param) for param in - self.DEFAULT_METADATA['parameters']], - program.parameters().metadata) - self.assertEqual([ProgramResult(**ret) for ret in - self.DEFAULT_METADATA['return_values']], - program.return_values) - self.assertEqual([ProgramResult(**ret) for ret in - self.DEFAULT_METADATA['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) + self._validate_program(program) def test_different_providers(self): """Test retrieving job submitted with different provider.""" @@ -636,23 +744,26 @@ 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) + 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, - 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, @@ -679,3 +790,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/ibm/runtime/test_runtime_integration.py b/test/ibm/runtime/test_runtime_integration.py index 39a0cd2ff..418be70e4 100644 --- a/test/ibm/runtime/test_runtime_integration.py +++ b/test/ibm/runtime/test_runtime_integration.py @@ -12,6 +12,7 @@ """Tests for runtime service.""" +import copy import unittest import os import uuid @@ -85,11 +86,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 IBMNotAuthorizedError: @@ -139,12 +141,40 @@ 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) 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(IBMNotAuthorizedError): + return program.data + def test_upload_program(self): """Test uploading a program.""" max_execution_time = 3000 @@ -200,13 +230,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() @@ -221,8 +244,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" @@ -232,11 +255,31 @@ def main(backend, user_messenger, **kwargs): return "version 2" """ 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.""" + 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.""" @@ -358,6 +401,31 @@ 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_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) @@ -626,7 +694,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, @@ -637,13 +705,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