diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index af571a2..c089f29 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -18,9 +18,7 @@ on: - cron: '0 4 * * 2' env: - PYTKET_REMOTE_IQM_AUTH_SERVER_URL: ${{ secrets.PYTKET_REMOTE_IQM_AUTH_SERVER_URL }} - PYTKET_REMOTE_IQM_USERNAME: ${{ secrets.PYTKET_REMOTE_IQM_USERNAME }} - PYTKET_REMOTE_IQM_PASSWORD: ${{ secrets.PYTKET_REMOTE_IQM_APIKEY }} + PYTKET_REMOTE_IQM_API_TOKEN: ${{ secrets.PYTKET_REMOTE_IQM_API_TOKEN }} jobs: iqm-checks: diff --git a/README.md b/README.md index ea99482..4e7a875 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,7 @@ compile it and run. Here is a small example of running a GHZ state circuit: from pytket.extensions.iqm import IQMBackend from pytket.circuit import Circuit -backend = IQMBackend( - url="https://demo.qc.iqm.fi/cocos", - auth_server_url="https://demo.qc.iqm.fi/auth", - username="USERNAME", - password="PASSWORD", -) +backend = IQMBackend(device="garnet", api_token="API_TOKEN") circuit = Circuit(3, 3) circuit.H(0) @@ -56,21 +51,23 @@ result = backend.run_circuit(compiled_circuit, n_shots=100) print(result.get_shots()) ``` +Note that the API token can be provided explicitly as an argument when +constructing the backend; alternatively it can be stored in pytket config (see +`IQMConfig.set_iqm_config()`); or it can be stored in a file whose location is +given by the environment variable `IQM_TOKENS_FILE`. + The IQM Client documentation includes the [set of currently supported instructions] -(https://iqm-finland.github.io/iqm-client/api/iqm_client.iqm_client.html). +(https://iqm-finland.github.io/iqm-client/api/iqm.iqm_client.models.Instruction.html). `pytket-iqm` retrieves the set from the IQM backend during the initialisation; then `get_compiled_circuit()` takes care of compiling the circuit into the form suitable to run on the backend. During the backend initialisation, `pytket-iqm` also retrieves the names of -physical qubits and qubit connectivity. You can override the qubit connectivity -by providing the `arch` parameter to the `IQMBackend` constructor, but it generally -does not make sense, since the IQM server reports the valid quantum architecture -relevant to the given backend URL. +physical qubits and qubit connectivity. (Note: At the moment IQM does not provide a quantum computing service open to the -general public. Please contact our [sales team](https://www.meetiqm.com/contact/) +general public. Please contact their [sales team](https://www.meetiqm.com/contact/) to set up your access to an IQM quantum computer.) ## Bugs and feature requests @@ -128,14 +125,10 @@ pytest ``` By default, the remote tests, which run against the real backend server, are -skipped. To enable them, set the following environment variables: - -```shell -export PYTKET_RUN_REMOTE_TESTS=1 -export PYTKET_REMOTE_IQM_AUTH_SERVER_URL=https://demo.qc.iqm.fi/auth -export PYTKET_REMOTE_IQM_USERNAME=YOUR_USERNAME -export PYTKET_REMOTE_IQM_PASSWORD=YOUR_PASSWORD -``` +skipped. To enable them, set the environment variable +`PYTKET_RUN_REMOTE_TESTS=1` and make sure you have your API token stored either +in pytket config or in a file whose location is given by the environment +variable `IQM_TOKENS_FILE`. When adding a new feature, please add a test for it. When fixing a bug, please add a test that demonstrates the fix. diff --git a/docs/changelog.rst b/docs/changelog.rst index 05cb6bc..54e9b81 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,7 +6,9 @@ Changelog 0.16.0 (unreleased) ------------------- -* Updated pytket version requirement to 1.39. +* Updated pytket version requirement to 1.40. +* Bring up to date with latest IQM client and server. +* Change parameters used to initialize IQMBackend. 0.15.0 (October 2024) --------------------- diff --git a/pytket/extensions/iqm/__init__.py b/pytket/extensions/iqm/__init__.py index ae57327..06f60ea 100644 --- a/pytket/extensions/iqm/__init__.py +++ b/pytket/extensions/iqm/__init__.py @@ -12,8 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Backends for processing pytket circuits with IQM devices -""" +"""Backends for processing pytket circuits with IQM devices""" # _metadata.py is copied to the folder after installation. from ._metadata import __extension_version__, __extension_name__ diff --git a/pytket/extensions/iqm/backends/__init__.py b/pytket/extensions/iqm/backends/__init__.py index 1ae3020..db38f27 100644 --- a/pytket/extensions/iqm/backends/__init__.py +++ b/pytket/extensions/iqm/backends/__init__.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Backends for processing pytket circuits with IQM devices -""" +"""Backends for processing pytket circuits with IQM devices""" from .iqm import IQMBackend diff --git a/pytket/extensions/iqm/backends/config.py b/pytket/extensions/iqm/backends/config.py index bd2eb1e..a90e446 100644 --- a/pytket/extensions/iqm/backends/config.py +++ b/pytket/extensions/iqm/backends/config.py @@ -25,32 +25,18 @@ class IQMConfig(PytketExtConfig): ext_dict_key: ClassVar[str] = "iqm" - auth_server_url: Optional[str] - username: Optional[str] - password: Optional[str] + api_token: Optional[str] @classmethod - def from_extension_dict( + def from_extension_dict( # type: ignore cls: Type["IQMConfig"], ext_dict: Dict[str, Any] ) -> "IQMConfig": - return cls( - ext_dict.get("auth_server_url"), - ext_dict.get("username"), - ext_dict.get("password"), - ) - - -def set_iqm_config( - auth_server_url: Optional[str] = None, - username: Optional[str] = None, - password: Optional[str] = None, -) -> None: + return cls(ext_dict.get("api_token")) + + +def set_iqm_config(api_token: Optional[str] = None) -> None: """Set default value for IQM API token.""" - config = IQMConfig.from_default_config_file() - if auth_server_url is not None: - config.auth_server_url = auth_server_url - if username is not None: - config.username = username - if password is not None: - config.password = password + config: IQMConfig = IQMConfig.from_default_config_file() + if api_token is not None: + config.api_token = api_token config.update_default_config_file() diff --git a/pytket/extensions/iqm/backends/iqm.py b/pytket/extensions/iqm/backends/iqm.py index 6a02add..9e98d0c 100644 --- a/pytket/extensions/iqm/backends/iqm.py +++ b/pytket/extensions/iqm/backends/iqm.py @@ -16,8 +16,9 @@ import os from typing import cast, Dict, List, Optional, Sequence, Tuple, Union from uuid import UUID -from iqm.iqm_client.iqm_client import Circuit as IQMCircuit -from iqm.iqm_client.iqm_client import Instruction, IQMClient, Metadata, Status +from iqm.iqm_client.iqm_client import IQMClient +from iqm.iqm_client.models import Circuit as IQMCircuit +from iqm.iqm_client.models import Instruction, Metadata, Status import numpy as np from pytket.backends import Backend, CircuitStatus, ResultHandle, StatusEnum from pytket.backends.backend import KwargTypes @@ -59,12 +60,14 @@ # Mapping of natively supported instructions' names to members of Pytket OpType _IQM_PYTKET_OP_MAP = { - "phased_rx": OpType.PhasedX, + "prx": OpType.PhasedX, "cz": OpType.CZ, - "measurement": OpType.Measure, + "measure": OpType.Measure, "barrier": OpType.Barrier, } +_SERVER_URL = "https://cocos.resonance.meetiqm.com" + class IqmAuthenticationError(Exception): """Raised when there is no IQM access credentials available.""" @@ -73,6 +76,13 @@ def __init__(self) -> None: super().__init__("No IQM access credentials provided or found in config file.") +class IqmDeviceUnsupportedError(Exception): + """Raised when we are unable to support a requested device.""" + + def __init__(self, reason: str) -> None: + super().__init__(reason) + + class IQMBackend(Backend): """ Interface to an IQM device or simulator. @@ -85,62 +95,47 @@ class IQMBackend(Backend): def __init__( self, - url: str, - arch: Optional[List[List[str]]] = None, - auth_server_url: Optional[str] = None, - username: Optional[str] = None, - password: Optional[str] = None, + device: str, + api_token: Optional[str] = None, ): """ Construct a new IQM backend. - Requires _either_ a valid auth server URL, username and password, _or_ a tokens - file. + Requires either a valid API token or a tokens file. - Auth server URL, username and password can either be provided as parameters or - set in config using :py:meth:`pytket.extensions.iqm.set_iqm_config`. + API token can either be provided as a parameter or set in config using + :py:meth:`pytket.extensions.iqm.set_iqm_config`. Path to the tokens file is read from the environmment variable ``IQM_TOKENS_FILE``. If set, this overrides any other credentials provided as arguments. - :param url: base URL for requests - :param arch: Optional list of couplings between the qubits defined, if - not set the default value from the server is used. - :param auth_server_url: base URL of authentication server - :param username: IQM username - :param password: IQM password + :param device: Name of device, e.g. "garnet" + :param api_token: API token """ super().__init__() - self._url = url - config = IQMConfig.from_default_config_file() + config: IQMConfig = IQMConfig.from_default_config_file() - if auth_server_url is None: - auth_server_url = config.auth_server_url + if api_token is None: + api_token = config.api_token tokens_file = os.getenv("IQM_TOKENS_FILE") - if username is None: - username = config.username - if password is None: - password = config.password - if (username is None or password is None) and tokens_file is None: + if api_token is None and tokens_file is None: raise IqmAuthenticationError() - + url = f"{_SERVER_URL}/{device}" if tokens_file is None: - self._client = IQMClient( - self._url, - auth_server_url=auth_server_url, - username=username, - password=password, - ) + self._client = IQMClient(url=url, token=api_token) else: - self._client = IQMClient(self._url, tokens_file=tokens_file) + self._client = IQMClient(url=url, tokens_file=tokens_file) _iqmqa = self._client.get_quantum_architecture() + # TODO We don't currently support resonator qubits or the "move" operation. + if "move" in _iqmqa.operations: + raise IqmDeviceUnsupportedError( + "Unable to support device with computational resonator" + ) self._operations = [_IQM_PYTKET_OP_MAP[op] for op in _iqmqa.operations] self._qubits = [_as_node(qb) for qb in _iqmqa.qubits] self._n_qubits = len(self._qubits) - if arch is None: - arch = _iqmqa.qubit_connectivity - coupling = [(_as_node(a), _as_node(b)) for (a, b) in arch] + coupling = [(_as_node(a), _as_node(b)) for (a, b) in _iqmqa.qubit_connectivity] if any(qb not in self._qubits for couple in coupling for qb in couple): raise ValueError("Architecture contains qubits not in device") self._arch = Architecture(coupling) @@ -263,9 +258,7 @@ def circuit_status(self, handle: ResultHandle) -> CircuitStatus: run_id = UUID(bytes=cast(bytes, handle[0])) run_result = self._client.get_run(run_id) status = run_result.status - if status == Status.PENDING_EXECUTION: - return CircuitStatus(StatusEnum.SUBMITTED) - elif status == Status.READY: + if status == Status.READY: measurements = cast(dict, run_result.measurements)[0] shots = OutcomeArray.from_readouts( np.array( @@ -281,9 +274,10 @@ def circuit_status(self, handle: ResultHandle) -> CircuitStatus: handle, {"result": BackendResult(shots=shots, ppcirc=ppcirc)} ) return CircuitStatus(StatusEnum.COMPLETED) - else: - assert status == Status.FAILED + elif status in [Status.FAILED, Status.ABORTED]: return CircuitStatus(StatusEnum.ERROR, cast(str, run_result.message)) + else: + return CircuitStatus(StatusEnum.SUBMITTED) def get_result(self, handle: ResultHandle, **kwargs: KwargTypes) -> BackendResult: """ @@ -339,10 +333,13 @@ def get_metadata(self, handle: ResultHandle, **kwargs: KwargTypes) -> Metadata: def _as_node(qname: str) -> Node: - assert qname.startswith("QB") - x = int(qname[2:]) - assert x >= 1 - return Node(x - 1) + if qname == "COMP_R": + return Node(0) + else: + assert qname.startswith("QB") + x = int(qname[2:]) + assert x >= 1 + return Node(x - 1) def _as_name(qnode: Node) -> str: @@ -360,14 +357,14 @@ def _translate_iqm(circ: Circuit) -> Tuple[Instruction, ...]: optype = op.type params = op.params if optype == OpType.PhasedX: - instr = Instruction( - name="phased_rx", + instr = Instruction( # type: ignore + name="prx", implementation=None, qubits=(str(qbs[0]),), args={"angle_t": 0.5 * params[0], "phase_t": 0.5 * params[1]}, ) elif optype == OpType.CZ: - instr = Instruction( + instr = Instruction( # type: ignore name="cz", implementation=None, qubits=(str(qbs[0]), str(qbs[1])), @@ -375,8 +372,8 @@ def _translate_iqm(circ: Circuit) -> Tuple[Instruction, ...]: ) else: assert optype == OpType.Measure - instr = Instruction( - name="measurement", + instr = Instruction( # type: ignore + name="measure", implementation=None, qubits=(str(qbs[0]),), args={"key": str(cbs[0])}, diff --git a/setup.py b/setup.py index 6fb31fa..62a4308 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ author_email="tket-support@quantinuum.com", python_requires=">=3.10", project_urls={ - "Documentation": "https://tket.quantinuum.com/extensions/pytket-iqm/index.html", + "Documentation": "https://docs.quantinuum.com/tket/extensions/pytket-iqm/index.html", "Source": "https://github.com/CQCL/pytket-iqm", "Tracker": "https://github.com/CQCL/pytket-iqm/issues", }, @@ -42,7 +42,7 @@ license="Apache 2", packages=find_namespace_packages(include=["pytket.*"]), include_package_data=True, - install_requires=["pytket >= 1.39.0", "iqm-client ~= 15.2"], + install_requires=["pytket >= 1.40.0", "iqm-client >= 20.15"], classifiers=[ "Environment :: Console", "Programming Language :: Python :: 3.10", diff --git a/tests/backend_test.py b/tests/backend_test.py index 2ffe36b..ca74f60 100644 --- a/tests/backend_test.py +++ b/tests/backend_test.py @@ -15,9 +15,8 @@ import os from uuid import UUID import pytest -from requests import get -from conftest import get_demo_url # type: ignore -from iqm.iqm_client.iqm_client import ClientAuthenticationError, Metadata, RunRequest +from iqm.iqm_client.errors import ClientAuthenticationError +from iqm.iqm_client.models import Metadata, RunRequest from pytket.circuit import Circuit from pytket.backends import StatusEnum from pytket.extensions.iqm import IQMBackend @@ -26,11 +25,6 @@ skip_remote_tests: bool = os.getenv("PYTKET_RUN_REMOTE_TESTS") is None REASON = "PYTKET_RUN_REMOTE_TESTS not set (requires configuration of IQM credentials)" -# Skip remote tests if the IQM demo site is unavailable -if skip_remote_tests is False and get(get_demo_url()).status_code != 200: - skip_remote_tests = True - REASON = "The IQM demo site/service is unavailable" - @pytest.mark.skipif(skip_remote_tests, reason=REASON) def test_iqm(authenticated_iqm_backend: IQMBackend, sample_circuit: Circuit) -> None: @@ -46,14 +40,9 @@ def test_iqm(authenticated_iqm_backend: IQMBackend, sample_circuit: Circuit) -> @pytest.mark.skipif(skip_remote_tests, reason=REASON) -def test_invalid_cred(demo_url: str) -> None: +def test_invalid_cred() -> None: with pytest.raises(ClientAuthenticationError): - _ = IQMBackend( - url=demo_url, - auth_server_url="https://demo.qc.iqm.fi/auth", - username="invalid", - password="invalid", - ) + _ = IQMBackend(device="garnet", api_token="invalid") @pytest.mark.skipif(skip_remote_tests, reason=REASON) diff --git a/tests/conftest.py b/tests/conftest.py index 7c6c8c0..098a6db 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,28 +18,14 @@ from pytket.circuit import Circuit -def get_demo_url() -> str: - return "https://demo.qc.iqm.fi/cocos" - - -@pytest.fixture(name="demo_url", scope="session") -def fixture_demo_url() -> str: - return get_demo_url() - - @pytest.fixture(name="authenticated_iqm_backend", scope="session") def fixture_authenticated_iqm_backend() -> IQMBackend: # Authenticated IQMBackend used for the remote tests - # The credentials are taken from the env variables: - # - PYTKET_REMOTE_IQM_AUTH_SERVER_URL - # - PYTKET_REMOTE_IQM_USERNAME - # - PYTKET_REMOTE_IQM_PASSWORD + # The credentials are taken from the env variable: + # - PYTKET_REMOTE_IQM_API_TOKEN return IQMBackend( - url=get_demo_url(), - auth_server_url=os.getenv("PYTKET_REMOTE_IQM_AUTH_SERVER_URL"), - username=os.getenv("PYTKET_REMOTE_IQM_USERNAME"), - password=os.getenv("PYTKET_REMOTE_IQM_PASSWORD"), + device="pyrite:test", api_token=os.getenv("PYTKET_REMOTE_IQM_API_TOKEN") ) diff --git a/tests/test-requirements.txt b/tests/test-requirements.txt index 4219d5a..3376dd2 100644 --- a/tests/test-requirements.txt +++ b/tests/test-requirements.txt @@ -1,5 +1,5 @@ pytest -pytest-timeout ~= 2.3.1 +pytest-timeout hypothesis requests_mock types-requests