Skip to content

Commit

Permalink
Better support for Resonance Cocos API (#158)
Browse files Browse the repository at this point in the history
* get run counts
* resonance specific API
* iqm-client client libraries interface
* update change log

---------

Signed-off-by: kukushechkin <kukushechkin@mac.com>
  • Loading branch information
kukushechkin authored Jan 3, 2025
1 parent 906b643 commit 2c8cfc8
Show file tree
Hide file tree
Showing 8 changed files with 294 additions and 65 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
Changelog
=========

Version 20.11
=============

* Add ``RESONANCE_COCOS_V1`` API variant option for Resonance Cocos API v1. `#158 <https://github.com/iqm-finland/iqm-client/pull/158>`_
* Add ``IQMClient::get_run_counts`` method. `#158 <https://github.com/iqm-finland/iqm-client/pull/158>`_
* Add ``IQMClient::get_supported_client_libraries`` method. `#158 <https://github.com/iqm-finland/iqm-client/pull/158>`_

Version 20.10
=============

Expand Down
14 changes: 13 additions & 1 deletion src/iqm/iqm_client/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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",
Expand Down
98 changes: 78 additions & 20 deletions src/iqm/iqm_client/iqm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -49,11 +50,13 @@
Circuit,
CircuitBatch,
CircuitCompilationOptions,
ClientLibrary,
DynamicQuantumArchitecture,
Instruction,
MoveGateValidationMode,
QuantumArchitecture,
QuantumArchitectureSpecification,
RunCounts,
RunRequest,
RunResult,
RunStatus,
Expand Down Expand Up @@ -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()}
59 changes: 59 additions & 0 deletions src/iqm/iqm_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
26 changes: 22 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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'})
Expand Down
13 changes: 10 additions & 3 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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")
Expand Down
Loading

0 comments on commit 2c8cfc8

Please sign in to comment.