diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ab871b0e8..357d0913d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,13 @@ Changelog ========= +Version 20.11 +============= + +* Add ``RESONANCE_COCOS_V1`` API variant option for Resonance Cocos API v1. `#158 `_ +* Add ``IQMClient::get_run_counts`` method. `#158 `_ +* Add ``IQMClient::get_supported_client_libraries`` method. `#158 `_ + Version 20.10 ============= diff --git a/src/iqm/iqm_client/api.py b/src/iqm/iqm_client/api.py index c5afc7860..45bac8bbe 100644 --- a/src/iqm/iqm_client/api.py +++ b/src/iqm/iqm_client/api.py @@ -58,8 +58,9 @@ class APIVariant(Enum): Use with caution, as future updates may introduce breaking changes or remove functionality entirely. """ - V1 = "V1" # IQM Resonance and Cocos-based circuits execution + V1 = "V1" # QCCSW Cocos-based circuits execution V2 = "V2" # Station-Control-based circuits execution + RESONANCE_COCOS_V1 = "RESONANCE_COCOS_V1" # IQM Resonance Cocos API class APIConfig: @@ -82,6 +83,17 @@ def _get_api_urls(self) -> dict[APIEndpoint, str]: Returns: Relative URLs for each supported API endpoints. """ + if self.variant == APIVariant.RESONANCE_COCOS_V1: + return { + APIEndpoint.SUBMIT_JOB: "jobs", + APIEndpoint.GET_JOB_RESULT: "jobs/%s", + APIEndpoint.GET_JOB_STATUS: "jobs/%s/status", + APIEndpoint.GET_JOB_COUNTS: "jobs/%s/counts", + APIEndpoint.ABORT_JOB: "jobs/%s/abort", + APIEndpoint.QUANTUM_ARCHITECTURE: "quantum-architecture", + APIEndpoint.CALIBRATED_GATES: "api/v1/calibration/%s/gates", + APIEndpoint.CLIENT_LIBRARIES: "info/client-libraries", + } if self.variant == APIVariant.V1: return { APIEndpoint.CONFIGURATION: "configuration", diff --git a/src/iqm/iqm_client/iqm_client.py b/src/iqm/iqm_client/iqm_client.py index 78110ea04..24289db46 100644 --- a/src/iqm/iqm_client/iqm_client.py +++ b/src/iqm/iqm_client/iqm_client.py @@ -11,6 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# pylint: disable=too-many-lines r""" Client for connecting to the IQM quantum computer server interface. """ @@ -49,11 +50,13 @@ Circuit, CircuitBatch, CircuitCompilationOptions, + ClientLibrary, DynamicQuantumArchitecture, Instruction, MoveGateValidationMode, QuantumArchitecture, QuantumArchitectureSpecification, + RunCounts, RunRequest, RunResult, RunStatus, @@ -936,25 +939,80 @@ def _check_versions(self) -> Optional[str]: compatibility could not be confirmed, None if they are compatible. """ try: - versions_response = requests.get( - self._api.url(APIEndpoint.CLIENT_LIBRARIES), - headers=self._default_headers(), - timeout=REQUESTS_TIMEOUT, + libraries = self.get_supported_client_libraries() + compatible_iqm_client = libraries.get( + 'iqm-client', + libraries.get('iqm_client'), ) - if versions_response.status_code == 200: - libraries = versions_response.json() - compatible_versions = libraries.get('iqm-client', libraries.get('iqm_client')) - min_version = parse(compatible_versions['min']) - max_version = parse(compatible_versions['max']) - client_version = parse(version('iqm-client')) - if client_version < min_version or client_version >= max_version: - return ( - f'Your IQM Client version {client_version} was built for a different version of IQM Server. ' - f'You might encounter issues. For the best experience, consider using a version ' - f'of IQM Client that satisfies {min_version} <= iqm-client < {max_version}.' - ) - return None - except Exception: # pylint: disable=broad-except + if compatible_iqm_client is None: + return 'Could not verify IQM Client compatibility with the server. You might encounter issues.' + min_version = parse(compatible_iqm_client.min) + max_version = parse(compatible_iqm_client.max) + client_version = parse(version('iqm-client')) + if client_version < min_version or client_version >= max_version: + return ( + f'Your IQM Client version {client_version} was built for a different version of IQM Server. ' + f'You might encounter issues. For the best experience, consider using a version ' + f'of IQM Client that satisfies {min_version} <= iqm-client < {max_version}.' + ) + return None + except Exception as e: # pylint: disable=broad-except # we don't want the version check to prevent usage of IQMClient in any situation - pass - return 'Could not verify IQM Client compatibility with the server. You might encounter issues.' + check_error = e + return f'Could not verify IQM Client compatibility with the server. You might encounter issues. {check_error}' + + def get_run_counts(self, job_id: UUID, *, timeout_secs: float = REQUESTS_TIMEOUT) -> RunCounts: + """Query the counts of an executed job. + + Args: + job_id: id of the job to query + timeout_secs: network request timeout + + Returns: + counts strings dictionary + + Raises: + CircuitExecutionError: IQM server specific exceptions + HTTPException: HTTP exceptions + """ + result = self._retry_request_on_error( + lambda: requests.get( + self._api.url(APIEndpoint.GET_JOB_COUNTS, str(job_id)), + headers=self._default_headers(), + timeout=timeout_secs, + ) + ) + + self._check_not_found_error(result) + result.raise_for_status() + try: + run_counts = RunCounts.from_dict(result.json()) + except (json.decoder.JSONDecodeError, KeyError) as e: + raise CircuitExecutionError(f'Invalid response: {result.text}, {e}') from e + + return run_counts + + def get_supported_client_libraries(self, timeout_secs: float = REQUESTS_TIMEOUT) -> dict[str, ClientLibrary]: + """Retrieves information about supported client libraries from the server. + + Args: + timeout_secs: network request timeout in seconds + + Returns: + Dictionary mapping library identifiers to their metadata + + Raises: + HTTPError: HTTP exceptions + ClientAuthenticationError: if authentication fails + """ + result = requests.get( + self._api.url(APIEndpoint.CLIENT_LIBRARIES), + headers=self._default_headers(), + timeout=timeout_secs, + ) + + self._check_not_found_error(result) + self._check_authentication_errors(result) + result.raise_for_status() + + return {key: ClientLibrary.model_validate(value) for key, value in result.json().items()} diff --git a/src/iqm/iqm_client/models.py b/src/iqm/iqm_client/models.py index 97986e879..b91edc932 100644 --- a/src/iqm/iqm_client/models.py +++ b/src/iqm/iqm_client/models.py @@ -11,6 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# pylint: disable=too-many-lines """This module contains the data models used by IQMClient.""" from __future__ import annotations @@ -960,3 +961,61 @@ def from_dict(inp: dict[str, Union[str, dict, list, None]]) -> RunStatus: """ input_copy = inp.copy() return RunStatus(status=Status(input_copy.pop('status')), **input_copy) + + +class Counts(BaseModel): + """Measurement results in the counts representation""" + + measurement_keys: list[str] + """measurement keys in the order they are concatenated to form the states in counts""" + counts: dict[str, int] + """counts as a dictionary mapping states represented as bitstrings to the number of shots they were measured""" + + +class RunCounts(BaseModel): + """Measurement counts of a circuit execution job.""" + + status: Status = Field(...) + """current status of the job, in ``{'pending compilation', 'pending execution', 'ready', 'failed', 'aborted'}``""" + counts_batch: Optional[list[Counts]] = Field( + None, + description="""Measurement results in histogram representation. + The `measurement_keys` list provides the order of the measurment keys for the repesentation of the states in the keys + of the `counts` dictionary. As an example if `measurement_keys` is `['mk_1', 'mk2']` and `mk_1` refers to `QB1` and `mk_2` + refers to `QB3` and `QB5` then counts could contains keys such as '010' with `QB1` in the 0, `QB3` in the 1 and `QB5` in + the 0 state.""", + ) + + @staticmethod + def from_dict(inp: dict[str, Union[str, dict, list, None]]) -> RunCounts: + """Parses the result from a dict. + + Args: + inp: value to parse, has to map to RunCounts + + Returns: + parsed job status + + """ + input_copy = inp.copy() + return RunCounts(status=Status(input_copy.pop('status')), **input_copy) + + +class ClientLibrary(BaseModel): + """Represents a client library with its metadata. + + Args: + name: display name of the client library. + package_name: name of the package as published in package repositories. + repo_url: URL to the source code repository. + package_url: URL to the package in the package repository. + min: minimum supported version. + max: maximum supported version. + """ + + name: str + package_name: Optional[str] = Field(None) + repo_url: Optional[str] = Field(None) + package_url: Optional[str] = Field(None) + min: str + max: str diff --git a/tests/conftest.py b/tests/conftest.py index 664a88f01..7659ac349 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -77,11 +77,8 @@ def missing_run_id() -> UUID: @pytest.fixture() def sample_client(base_url) -> IQMClient: - client_version = parse(version('iqm-client')) when(requests).get(f'{base_url}/info/client-libraries', headers=ANY, timeout=ANY).thenReturn( - MockJsonResponse( - 200, {'iqm-client': {'min': f'{client_version.major}.0', 'max': f'{client_version.major + 1}.0'}} - ) + mock_supported_client_libraries_response() ) client = IQMClient(url=base_url) client._token_manager = None # Do not use authentication @@ -90,6 +87,9 @@ def sample_client(base_url) -> IQMClient: @pytest.fixture() def client_with_signature(base_url) -> IQMClient: + when(requests).get(f'{base_url}/info/client-libraries', headers=ANY, timeout=ANY).thenReturn( + mock_supported_client_libraries_response() + ) client = IQMClient(url=base_url, client_signature='some-signature') client._token_manager = None # Do not use authentication return client @@ -558,6 +558,24 @@ def raise_for_status(self): raise HTTPError(f'{self.status_code}', response=self) +def mock_supported_client_libraries_response( + iqm_client_name: str = 'iqm-client', max_version: Optional[str] = None, min_version: Optional[str] = None +) -> MockJsonResponse: + client_version = parse(version('iqm-client')) + min_version = f'{client_version.major}.0' if min_version is None else min_version + max_version = f'{client_version.major + 1}.0' if max_version is None else max_version + return MockJsonResponse( + 200, + { + iqm_client_name: { + 'name': iqm_client_name, + 'min': min_version, + 'max': max_version, + } + }, + ) + + @pytest.fixture() def not_valid_client_configuration_response() -> MockJsonResponse: return MockJsonResponse(400, {'detail': 'not a valid client configuration'}) diff --git a/tests/test_api.py b/tests/test_api.py index f2af3b294..edb25e027 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -12,10 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. """Tests for the IQM client API.""" +from mockito import ANY, when import pytest +import requests from iqm.iqm_client.api import APIConfig, APIEndpoint, APIVariant from iqm.iqm_client.iqm_client import IQMClient +from tests.conftest import mock_supported_client_libraries_response @pytest.fixture @@ -30,10 +33,14 @@ def test_api_config_initialization(sample_api_config): assert isinstance(sample_api_config.urls, dict) -@pytest.mark.parametrize("variant", [None, APIVariant.V1, APIVariant.V2]) +@pytest.mark.parametrize("variant", [None, APIVariant.V1, APIVariant.V2, APIVariant.RESONANCE_COCOS_V1]) def test_api_config_is_v1_by_default(variant): """Test that APIConfig is V1 by default for backward compatibility.""" - iqm_client = IQMClient("https://example.com", api_variant=variant) + base_url = "https://example.com" + when(requests).get(f"{base_url}/info/client-libraries", headers=ANY, timeout=ANY).thenReturn( + mock_supported_client_libraries_response() + ) + iqm_client = IQMClient(base_url, api_variant=variant) assert iqm_client._api.variant == variant if variant is not None else APIVariant.V1 @@ -43,7 +50,7 @@ def test_api_config_get_api_urls_invalid_variant(): APIConfig("INVALID", "https://example.com")._get_api_urls() -@pytest.mark.parametrize("variant", [APIVariant.V1, APIVariant.V2]) +@pytest.mark.parametrize("variant", [APIVariant.V1, APIVariant.V2, APIVariant.RESONANCE_COCOS_V1]) def test_api_config_is_supported(variant): """Test that is_supported returns correct values""" api_config = APIConfig(variant, "https://example.com") diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 8c7046c61..729ee1cad 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -41,7 +41,7 @@ TokenProviderInterface, TokensFileReader, ) -from tests.conftest import MockJsonResponse, make_token +from tests.conftest import MockJsonResponse, make_token, mock_supported_client_libraries_response @pytest.fixture() @@ -68,6 +68,15 @@ def auth_password() -> str: return 'very-secret' +def _make_client_with_token(base_url) -> tuple[str, IQMClient]: + when(requests).get(f'{base_url}/info/client-libraries', headers=ANY, timeout=ANY).thenReturn( + mock_supported_client_libraries_response() + ) + token = make_token('Bearer', 300) + client = IQMClient(base_url, token=token) + return token, client + + def test_external_token_provides_token(): """Tests that ExternalToken provides the configured token""" token = make_token('Bearer', 300) @@ -510,9 +519,7 @@ def test_submit_circuits_gets_token( ): """Test that submit_circuits gets bearer token from TokenManager""" _patch_env(monkeypatch.setenv) - - token = make_token('Bearer', 300) - client = IQMClient(base_url, token=token) + token, client = _make_client_with_token(base_url) when(requests).get( dynamic_architecture_url, @@ -536,9 +543,7 @@ def test_submit_circuits_gets_token( def test_get_run_gets_token(monkeypatch, base_url, jobs_url, ready_job_result): """Test that get_run gets bearer token from TokenManager""" _patch_env(monkeypatch.setenv) - - token = make_token('Bearer', 300) - client = IQMClient(base_url, token=token) + token, client = _make_client_with_token(base_url) job_id = uuid4() expect(requests, times=1).get( @@ -556,9 +561,7 @@ def test_get_run_gets_token(monkeypatch, base_url, jobs_url, ready_job_result): def test_get_run_status_gets_token(monkeypatch, base_url, jobs_url, pending_compilation_status): """Test that get_run gets bearer token from TokenManager""" _patch_env(monkeypatch.setenv) - - token = make_token('Bearer', 300) - client = IQMClient(base_url, token=token) + token, client = _make_client_with_token(base_url) job_id = uuid4() expect(requests, times=1).get( @@ -576,9 +579,7 @@ def test_get_run_status_gets_token(monkeypatch, base_url, jobs_url, pending_comp def test_abort_job_gets_token(monkeypatch, base_url, jobs_url): """Test that abort_job gets bearer token from TokenManager""" _patch_env(monkeypatch.setenv) - - token = make_token('Bearer', 300) - client = IQMClient(base_url, token=token) + token, client = _make_client_with_token(base_url) job_id = uuid4() expect(requests, times=1).post( @@ -596,9 +597,7 @@ def test_abort_job_gets_token(monkeypatch, base_url, jobs_url): def test_close_auth_session(monkeypatch, base_url): """Test that closing auth session closes TokenManager""" _patch_env(monkeypatch.setenv) - - token = make_token('Bearer', 300) - client = IQMClient(base_url, token=token) + _, client = _make_client_with_token(base_url) client._token_manager = mock(TokenManager) expect(client._token_manager, times=1).close().thenReturn(True) @@ -611,9 +610,7 @@ def test_close_auth_session(monkeypatch, base_url): def test_close_auth_session_when_client_destroyed(monkeypatch, base_url): """Test that deleting client closes TokenManager""" _patch_env(monkeypatch.setenv) - - token = make_token('Bearer', 300) - client = IQMClient(base_url, token=token) + _, client = _make_client_with_token(base_url) client._token_manager = mock(TokenManager) expect(client._token_manager, times=1).close().thenReturn(True) diff --git a/tests/test_iqm_client.py b/tests/test_iqm_client.py index e18e806c6..451b0ad19 100644 --- a/tests/test_iqm_client.py +++ b/tests/test_iqm_client.py @@ -32,6 +32,7 @@ CircuitExecutionError, CircuitValidationError, ClientConfigurationError, + Counts, DynamicQuantumArchitecture, HeraldingMode, Instruction, @@ -43,7 +44,13 @@ serialize_qubit_mapping, validate_circuit, ) -from tests.conftest import MockJsonResponse, get_jobs_args, post_jobs_args, submit_circuits_args +from tests.conftest import ( + MockJsonResponse, + get_jobs_args, + mock_supported_client_libraries_response, + post_jobs_args, + submit_circuits_args, +) @pytest.fixture @@ -1018,15 +1025,7 @@ def test_get_dynamic_quantum_architecture_not_found(base_url, sample_client): min_version = f'{client_version.major + 2}.0' max_version = f'{client_version.major + 3}.0' when(requests).get(f'{base_url}/info/client-libraries', headers=ANY, timeout=ANY).thenReturn( - MockJsonResponse( - 200, - { - 'iqm-client': { - 'min': min_version, - 'max': max_version, - } - }, - ) + mock_supported_client_libraries_response(min_version=min_version, max_version=max_version) ) when(requests).get(f'{base_url}/api/v1/calibration/default/gates', ...).thenReturn(MockJsonResponse(404, {})) with pytest.raises( @@ -1054,14 +1053,8 @@ def test_check_versions_success(base_url, iqm_client_name, server_version_diff, min_version = f'{client_version.major + server_version_diff}.0' max_version = f'{client_version.major + server_version_diff + 1}.0' when(requests).get(f'{base_url}/info/client-libraries', headers=ANY, timeout=ANY).thenReturn( - MockJsonResponse( - 200, - { - iqm_client_name: { - 'min': min_version, - 'max': max_version, - } - }, + mock_supported_client_libraries_response( + iqm_client_name=iqm_client_name, min_version=min_version, max_version=max_version ) ) if server_version_diff == 0: @@ -1107,3 +1100,81 @@ def test_check_versions_request_exception(base_url): ): IQMClient(base_url) unstub() + + +def test_get_run_counts(base_url, sample_client, existing_run_id): + """Test that the number of runs is returned.""" + expect(requests, times=1).get(f'{base_url}/jobs/{existing_run_id}/counts', ...).thenReturn( + MockJsonResponse( + 200, + { + 'status': 'ready', + 'counts_batch': [ + { + 'measurement_keys': ['m1'], + 'counts': {'0': 5, '1': 5}, + }, + { + 'measurement_keys': ['m2'], + 'counts': {'0': 1, '1': 9}, + }, + ], + 'warnings': [], + }, + ) + ) + counts = sample_client.get_run_counts(existing_run_id) + assert counts.status == Status.READY + assert counts.counts_batch == [ + Counts(measurement_keys=['m1'], counts={'0': 5, '1': 5}), + Counts(measurement_keys=['m2'], counts={'0': 1, '1': 9}), + ] + verifyNoUnwantedInteractions() + unstub() + + +def test_get_supported_client_libraries(base_url, sample_client): + """Test retrieving client library information from server.""" + libraries_data = { + 'iqm-client': { + 'name': 'IQM Client', + 'package_name': 'iqm-client', + 'repo_url': 'https://github.com/iqm-finland/iqm-client', + 'package_url': 'https://pypi.org/project/iqm-client', + 'min': '14.0.0', + 'max': '15.0.0', + 'images': None, + }, + 'iqm-cortex-cli': { + 'name': 'IQM Cortex CLI', + 'package_name': 'iqm-cortex-cli', + 'repo_url': 'https://github.com/iqm-finland/cortex-cli', + 'package_url': 'https://pypi.org/project/iqm-cortex-cli', + 'min': '1.0.0', + 'max': '2.0.0', + 'images': [], + }, + } + + expect(requests, times=1).get(f'{base_url}/info/client-libraries', headers=ANY, timeout=ANY).thenReturn( + MockJsonResponse(200, libraries_data) + ) + + libraries = sample_client.get_supported_client_libraries() + + # Verify returned data matches expected structure + assert len(libraries) == 2 + assert 'iqm-client' in libraries + assert 'iqm-cortex-cli' in libraries + + # Verify specific library details + iqm_client = libraries['iqm-client'] + assert iqm_client.name == 'IQM Client' + assert iqm_client.package_name == 'iqm-client' + assert iqm_client.repo_url == 'https://github.com/iqm-finland/iqm-client' + assert iqm_client.package_url == 'https://pypi.org/project/iqm-client' + assert iqm_client.min == '14.0.0' + assert iqm_client.max == '15.0.0' + + verifyNoUnwantedInteractions() + unstub()