diff --git a/.vscode/settings.json b/.vscode/settings.json index c58052a..0181cbd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,10 @@ { "python.analysis.extraPaths": [ "qiskit_quantuminspire" - ] -} + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.testing.pytestPath": "${command:python.interpreterPath}", + "python.testing.autoTestDiscoverOnSaveEnabled": true, + "python.testing.cwd": "${workspaceFolder}", +} \ No newline at end of file diff --git a/qiskit_quantuminspire/qi_jobs.py b/qiskit_quantuminspire/qi_jobs.py index 8e1bd3a..2d79448 100644 --- a/qiskit_quantuminspire/qi_jobs.py +++ b/qiskit_quantuminspire/qi_jobs.py @@ -1,14 +1,28 @@ -from datetime import datetime, timezone -from typing import Any, List, Union +import asyncio +from dataclasses import dataclass +from functools import cache +from typing import Any, Dict, List, Optional, Union -from compute_api_client import Result as JobResult +from compute_api_client import ApiClient, PageResult, Result as RawJobResult, ResultsApi from qiskit.circuit import QuantumCircuit from qiskit.providers import JobV1 as Job from qiskit.providers.backend import Backend from qiskit.providers.jobstatus import JobStatus +from qiskit.qobj import QobjExperimentHeader +from qiskit.result.models import ExperimentResult, ExperimentResultData from qiskit.result.result import Result -from qiskit_quantuminspire.qi_results import QIResult +from qiskit_quantuminspire.api.client import config +from qiskit_quantuminspire.api.pagination import PageReader + + +@dataclass +class CircuitExecutionData: + """Class for book-keeping of individual jobs.""" + + circuit: QuantumCircuit + job_id: Optional[int] = None + results: Optional[RawJobResult] = None # Ignore type checking for QIJob due to missing Qiskit type stubs, @@ -21,58 +35,110 @@ def __init__( run_input: Union[QuantumCircuit, List[QuantumCircuit]], backend: Union[Backend, None], job_id: str, - **kwargs: Any + **kwargs: Any, ) -> None: """Initialize a QIJob instance. Args: - run_input: A single/list of Qiskit QuantumCircuit objects or hybrid algorithms. + run_input: A single/list of Qiskit QuantumCircuit object(s). backend: The backend on which the job is run. While specified as `Backend` to avoid circular dependency, it is a `QIBackend`. job_id: A unique identifier for the (batch)job. **kwargs: Additional keyword arguments passed to the parent `Job` class. """ super().__init__(backend, job_id, **kwargs) - self._job_ids: List[str] = [] - self._run_input = run_input + self.circuits_run_data: List[CircuitExecutionData] = ( + [CircuitExecutionData(circuit=run_input)] + if isinstance(run_input, QuantumCircuit) + else [CircuitExecutionData(circuit=circuit) for circuit in run_input] + ) def submit(self) -> None: """Submit the (batch)job to the quantum inspire backend. Use compute-api-client to call the cjm endpoints in the correct order, to submit the jobs. """ - for i in range(1, 3): - self._job_ids.append(str(i)) + # Here, we will update the self.circuits_run_data and attach the job ids for each circuit + for _ in range(1, 3): + pass self.job_id = "999" # ID of the submitted batch-job - def _fetch_job_results(self) -> List[JobResult]: + async def _fetch_job_results(self) -> None: """Fetch results for job_ids from CJM using api client.""" - raw_results = [] - for job_id in self._job_ids: - job_result = JobResult( - id=int(job_id), - metadata_id=1, - created_on=datetime(2022, 10, 25, 15, 37, 54, 269823, tzinfo=timezone.utc), - execution_time_in_seconds=1.23, - shots_requested=100, - shots_done=100, - results={ - "0000000000": 0.270000, - "0000000001": 0.260000, - "0000000010": 0.180000, - "0000000011": 0.290000, - }, - job_id=int(job_id), - ) - raw_results.append(job_result) - return raw_results + async with ApiClient(config()) as client: + page_reader = PageReader[PageResult, RawJobResult]() + results_api = ResultsApi(client) + pagination_handler = page_reader.get_all + results_handler = results_api.read_results_by_job_id_results_job_job_id_get + result_tasks = [ + pagination_handler(results_handler, job_id=circuit_data.job_id) + for circuit_data in self.circuits_run_data + ] + result_items = await asyncio.gather(*result_tasks) + + for circuit_data, result_item in zip(self.circuits_run_data, result_items): + circuit_data.results = None if not result_item else result_item[0] + + @cache def result(self) -> Result: """Return the results of the job.""" - raw_results = self._fetch_job_results() - processed_results = QIResult(raw_results).process(self) - return processed_results + if not self.done(): + raise RuntimeError(f"(Batch)Job status is {self.status()}.") + asyncio.run(self._fetch_job_results()) + return self._process_results() def status(self) -> JobStatus: """Return the status of the (batch)job, among the values of ``JobStatus``.""" return JobStatus.DONE + + def _process_results(self) -> Result: + """Process the raw job results obtained from QuantumInspire.""" + + results = [] + batch_job_success = [False] * len(self.circuits_run_data) + + for idx, circuit_data in enumerate(self.circuits_run_data): + qi_result = circuit_data.results + circuit_name = circuit_data.circuit.name + + if qi_result is None: + experiment_result = self._get_experiment_result(circuit_name=circuit_name) + results.append(experiment_result) + continue + experiment_result = self._get_experiment_result( + circuit_name=circuit_name, + shots=qi_result.shots_done, + counts={hex(int(key, 2)): value for key, value in qi_result.results.items()}, + experiment_success=qi_result.shots_done > 0, + ) + results.append(experiment_result) + batch_job_success[idx] = qi_result.shots_done > 0 + + result = Result( + backend_name=self.backend().name, + backend_version="1.0.0", + qobj_id="", + job_id=self.job_id, + success=all(batch_job_success), + results=results, + ) + return result + + @staticmethod + def _get_experiment_result( + circuit_name: str, + shots: int = 0, + counts: Optional[Dict[str, int]] = None, + experiment_success: bool = False, + ) -> ExperimentResult: + """Create an ExperimentResult instance based on RawJobResult parameters.""" + experiment_data = ExperimentResultData( + counts={} if counts is None else counts, + ) + return ExperimentResult( + shots=shots, + success=experiment_success, + data=experiment_data, + header=QobjExperimentHeader(name=circuit_name), + ) diff --git a/qiskit_quantuminspire/qi_results.py b/qiskit_quantuminspire/qi_results.py deleted file mode 100644 index 7cf2536..0000000 --- a/qiskit_quantuminspire/qi_results.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import List - -from compute_api_client import Result as RawJobResult -from qiskit.providers import JobV1 as Job -from qiskit.result.models import ExperimentResult, ExperimentResultData -from qiskit.result.result import Result - - -class QIResult: - """Handle QuantumInspire (batch job) results to integrate with Qiskit's Result interface.""" - - def __init__(self, qinspire_results: List[RawJobResult]) -> None: - self._raw_results = qinspire_results - - def process(self, job: Job) -> Result: - """Process the raw job results obtained from QuantumInspire. - - Args: - job: The (batch) job for which the results were obtained. While specified as `Job` - to avoid circular dependency, it is a `QIJob`. - Returns: - The processed results as a Qiskit Result. - """ - - results = [] - - for result in self._raw_results: - experiment_result = ExperimentResult( - shots=result.shots_done, - success=True, - data=ExperimentResultData(), - ) - results.append(experiment_result) - - result = Result( - backend_name=job.backend().name, - backend_version="1.0.0", - qobj_id=1234, - job_id=job.job_id, - success=True, - results=results, - ) - return result diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b6eb384 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +from unittest.mock import AsyncMock + +import pytest +from pytest_mock import MockFixture + +from qiskit_quantuminspire.api.pagination import PageReader + + +@pytest.fixture +def page_reader_mock(mocker: MockFixture) -> AsyncMock: + # Simply calling mocker.patch() doesn't work because PageReader is a generic class + page_reader_mock = AsyncMock() + page_reader_mock.get_all = AsyncMock() + mocker.patch.object(PageReader, "get_all", page_reader_mock.get_all) + return page_reader_mock diff --git a/tests/test_jobs.py b/tests/test_jobs.py new file mode 100644 index 0000000..b899c32 --- /dev/null +++ b/tests/test_jobs.py @@ -0,0 +1,186 @@ +import asyncio +from datetime import datetime, timezone +from typing import List, Union +from unittest.mock import AsyncMock, MagicMock + +import pytest +from compute_api_client import Result as RawJobResult +from pytest_mock import MockerFixture +from qiskit import QuantumCircuit +from qiskit.qobj import QobjExperimentHeader +from qiskit.result.models import ExperimentResult, ExperimentResultData +from qiskit.result.result import Result + +from qiskit_quantuminspire.qi_jobs import QIJob +from tests.helpers import create_backend_type + + +@pytest.fixture +def mock_configs_apis(mocker: MockerFixture) -> None: + mocker.patch( + "qiskit_quantuminspire.qi_jobs.config", + return_value=MagicMock(), + ) + mocker.patch( + "qiskit_quantuminspire.qi_jobs.ApiClient", + autospec=True, + ) + + mocker.patch( + "qiskit_quantuminspire.qi_jobs.ResultsApi", + autospec=True, + ) + + +def test_result(mocker: MockerFixture) -> None: + + qc = QuantumCircuit(2, 2) + + job = QIJob(run_input=qc, backend=None, job_id="some-id") + + mocker.patch.object(job, "done", return_value=True) + + mock_fetch_job_results = AsyncMock(return_value=MagicMock()) + mocker.patch.object(job, "_fetch_job_results", mock_fetch_job_results) + + mock_process_results = MagicMock(return_value=MagicMock()) + mocker.patch.object(job, "_process_results", mock_process_results) + + for _ in range(4): # Check caching + job.result() + + mock_process_results.assert_called_once() + mock_fetch_job_results.assert_called_once() + + +def test_result_raises_error_when_status_not_done(mocker: MockerFixture) -> None: + job = QIJob(run_input="", backend=None, job_id="some-id") + mocker.patch.object(job, "done", return_value=False) + with pytest.raises(RuntimeError): + job.result() + + +@pytest.mark.parametrize( + "circuits, expected_n_jobs", + [ + (QuantumCircuit(1, 1), 1), # Single circuit + ([QuantumCircuit(1, 1), QuantumCircuit(2, 2)], 2), # List of circuits + ], +) +def test_fetch_job_result( + page_reader_mock: AsyncMock, + circuits: Union[QuantumCircuit, List[QuantumCircuit]], + expected_n_jobs: int, + mock_configs_apis: None, +) -> None: + + page_reader_mock.get_all.side_effect = [[MagicMock()] for _ in range(expected_n_jobs)] + + job = QIJob(run_input=circuits, backend=None, job_id="some-id") + + asyncio.run(job._fetch_job_results()) + + assert len(job.circuits_run_data) == expected_n_jobs + + assert all(circuit_data.results for circuit_data in job.circuits_run_data) + + +def test_fetch_job_result_handles_invalid_results( + page_reader_mock: AsyncMock, + mock_configs_apis: None, +) -> None: + + circuits = [QuantumCircuit(1, 1), QuantumCircuit(2, 2)] + + page_reader_mock.get_all.side_effect = [[], [None]] + + job = QIJob(run_input=circuits, backend=None, job_id="some-id") + + asyncio.run(job._fetch_job_results()) + + assert all(circuit_data.results is None for circuit_data in job.circuits_run_data) + + assert len(job.circuits_run_data) == len(circuits) + + +def test_process_results() -> None: + qi_backend = create_backend_type(name="qi_backend_1") + qc = QuantumCircuit(2, 2) + + qi_job = QIJob(run_input=qc, backend=qi_backend, job_id="some-id") + batch_job_id = "100" + qi_job.job_id = batch_job_id + individual_job_id = 1 + qi_job.circuits_run_data[0].job_id = 1 # Individual job_id + qi_job.circuits_run_data[0].results = RawJobResult( + id=individual_job_id, + metadata_id=1, + created_on=datetime(2022, 10, 25, 15, 37, 54, 269823, tzinfo=timezone.utc), + execution_time_in_seconds=1.23, + shots_requested=100, + shots_done=100, + results={ + "0000000000": 256, + "0000000001": 256, + "0000000010": 256, + "0000000011": 256, + }, + job_id=int(qi_job.job_id), + ) + processed_results = qi_job._process_results() + experiment_data = ExperimentResultData(counts={"0x0": 256, "0x1": 256, "0x2": 256, "0x3": 256}) + experiment_result = ExperimentResult( + shots=100, + success=True, + meas_level=2, + data=experiment_data, + header=QobjExperimentHeader(name=qi_job.circuits_run_data[0].circuit.name), + ) + expected_results = Result( + backend_name="qi_backend_1", + backend_version="1.0.0", + qobj_id="", + job_id=batch_job_id, + success=True, + results=[experiment_result], + date=None, + status=None, + header=None, + ) + assert processed_results.to_dict() == expected_results.to_dict() + assert processed_results.data(qc) == experiment_data.to_dict() + + +def test_process_results_handles_invalid_results() -> None: + + qi_backend = create_backend_type(name="qi_backend_1") + qc = QuantumCircuit(2, 2) + + qi_job = QIJob(run_input=qc, backend=qi_backend, job_id="some-id") + batch_job_id = "100" + qi_job.job_id = batch_job_id + qi_job.circuits_run_data[0].job_id = 1 # Individual job_id + + qi_job.circuits_run_data[0].results = None + + processed_results = qi_job._process_results() + expected_results = Result( + backend_name="qi_backend_1", + backend_version="1.0.0", + qobj_id="", + job_id=batch_job_id, + success=False, + results=[ + ExperimentResult( + shots=0, + success=False, + meas_level=2, + data=ExperimentResultData(counts={}), + header=QobjExperimentHeader(name=qi_job.circuits_run_data[0].circuit.name), + ) + ], + date=None, + status=None, + header=None, + ) + assert processed_results.to_dict() == expected_results.to_dict() diff --git a/tests/test_qi_provider.py b/tests/test_qi_provider.py index b0bd36d..0edd954 100644 --- a/tests/test_qi_provider.py +++ b/tests/test_qi_provider.py @@ -4,7 +4,6 @@ import pytest from pytest_mock import MockFixture -from qiskit_quantuminspire.api.pagination import PageReader from qiskit_quantuminspire.qi_backend import QIBackend from qiskit_quantuminspire.qi_provider import QIProvider from tests.helpers import create_backend_type @@ -17,15 +16,6 @@ def mock_api(mocker: MockFixture) -> None: mocker.patch("qiskit_quantuminspire.qi_provider.BackendTypesApi") -@pytest.fixture -def page_reader_mock(mocker: MockFixture) -> AsyncMock: - # Simply calling mocker.patch() doesn't work because PageReader is a generic class - page_reader_mock = AsyncMock() - page_reader_mock.get_all = AsyncMock() - mocker.patch.object(PageReader, "get_all", page_reader_mock.get_all) - return page_reader_mock - - def test_qi_provider_construct(mock_api: Any, page_reader_mock: AsyncMock) -> None: # Arrange page_reader_mock.get_all.return_value = [