From 0cae5b4a8c1dbeaaac9162ff103ccc0d54c69fc8 Mon Sep 17 00:00:00 2001 From: Will Shanks Date: Tue, 10 Dec 2024 17:03:20 -0500 Subject: [PATCH] Improve handling of circuit execution providers This change improves several aspects about the way `ExperimentData` works with "providers." Here "provider" is generalized beyond `qiskit.providers.Provider` to any object which can provide a `Job`-like object with data about an experiment's circuit execution. This generalization is needed because `qiskit-ibm-runtime`, the main provider that Experiments integrates with, does not use the `Provider` class, so * Replace references to `qiskit.providers.Provider` and `qiskit.providers.Job` with custom interface definitions using `typing.Protocol`. The new interfaces document the API Experiments needs for working with these objects. * Improve type hints and type checking related to providers, jobs, and results, using the new protocol classes. * Infer `IBMExperimentService` authentication parameters from a `qiskit_ibm_runtime.QiskitRuntimeService` instance in the same way that the inference used to work with `qiskit_ibm_provider.IBMProvider`. * Delay inferring an `IBMExperimentService` from a backend or provider until `ExperimentData` tries to communicate with the experiment service. With the fix to infer the authentication parameters from `QiskitRuntimeService`, this delay is needed to avoid breaking existing code that creates an `ExperimentData` instance and then tries to set a custom experiment service for it, relying on the fact that inferrence did not work for `QiskitRuntimeService`, since setting the service on `ExperimentData` after it has already been set once raises an exception. * Remove dead code, both helper functions that are not called anywhere in the repository and code paths only relevant to qiskit-ibm-provider, like references to `time_per_step()`. Since `qiskit-ibm-provider` has long been deprecated and unsupported by IBM Quantum, removing support for it is not treated as a breaking change. * Handle some optional data types better (like result objects that might have a `metadata` attribute). --- qiskit_experiments/framework/__init__.py | 7 + .../framework/experiment_data.py | 254 ++++++++---------- .../framework/provider_interfaces.py | 108 ++++++++ ...ovider-service-types-47c262d5434047e5.yaml | 23 ++ 4 files changed, 251 insertions(+), 141 deletions(-) create mode 100644 qiskit_experiments/framework/provider_interfaces.py create mode 100644 releasenotes/notes/provider-service-types-47c262d5434047e5.yaml diff --git a/qiskit_experiments/framework/__init__.py b/qiskit_experiments/framework/__init__.py index 2f4304ebf2..31b064bbf2 100644 --- a/qiskit_experiments/framework/__init__.py +++ b/qiskit_experiments/framework/__init__.py @@ -93,6 +93,12 @@ ExperimentDecoder ArtifactData FigureData + Provider + BaseProvider + IBMProvider + Job + BaseJob + ExtendedJob .. _composite-experiment: @@ -154,4 +160,5 @@ CompositeAnalysis, ) from .json import ExperimentEncoder, ExperimentDecoder +from .provider_interfaces import BaseJob, BaseProvider, ExtendedJob, IBMProvider, Job, Provider from .restless_mixin import RestlessMixin diff --git a/qiskit_experiments/framework/experiment_data.py b/qiskit_experiments/framework/experiment_data.py index a4cd953967..8f8a37ef74 100644 --- a/qiskit_experiments/framework/experiment_data.py +++ b/qiskit_experiments/framework/experiment_data.py @@ -21,6 +21,7 @@ from concurrent import futures from functools import wraps from collections import deque, defaultdict +from collections.abc import Iterable import contextlib import copy import uuid @@ -31,14 +32,14 @@ import warnings import numpy as np import pandas as pd -from dateutil import tz from matplotlib import pyplot from qiskit.result import Result +from qiskit.primitives import PrimitiveResult from qiskit.providers.jobstatus import JobStatus, JOB_FINAL_STATES from qiskit.exceptions import QiskitError -from qiskit.providers import Job, Backend +from qiskit.providers import Backend from qiskit.utils.deprecation import deprecate_arg -from qiskit.primitives import BitArray, SamplerPubResult, BasePrimitiveJob +from qiskit.primitives import BitArray, SamplerPubResult from qiskit_ibm_experiment import ( IBMExperimentService, @@ -69,6 +70,8 @@ from qiskit_experiments.database_service.utils import objs_to_zip, zip_to_objs from .containers.figure_data import FigureData, FigureType +from .provider_interfaces import Job, Provider + if TYPE_CHECKING: # There is a cyclical dependency here, but the name needs to exist for @@ -76,7 +79,6 @@ # `TYPE_CHECKING` means that the import will never be resolved by an actual # interpreter, only static analysis. from . import BaseExperiment - from qiskit.providers import Provider LOG = logging.getLogger(__name__) @@ -94,36 +96,6 @@ def _wrapped(self, *args, **kwargs): return _wrapped -def utc_to_local(utc_dt: datetime) -> datetime: - """Convert input UTC timestamp to local timezone. - - Args: - utc_dt: Input UTC timestamp. - - Returns: - A ``datetime`` with the local timezone. - """ - if utc_dt is None: - return None - local_dt = utc_dt.astimezone(tz.tzlocal()) - return local_dt - - -def local_to_utc(local_dt: datetime) -> datetime: - """Convert input local timezone timestamp to UTC timezone. - - Args: - local_dt: Input local timestamp. - - Returns: - A ``datetime`` with the UTC timezone. - """ - if local_dt is None: - return None - utc_dt = local_dt.astimezone(tz.UTC) - return utc_dt - - def parse_utc_datetime(dt_str: str) -> datetime: """Parses UTC datetime from a string""" if dt_str is None: @@ -259,14 +231,17 @@ def __init__( self._backend = None if backend is not None: self._set_backend(backend, recursive=False) - self.provider = provider + self._provider = provider if provider is None and backend is not None: - self.provider = backend.provider + # BackendV2 has a provider attribute but BackendV3 probably will not + self._provider = getattr(backend, "provider", None) + if self.provider is None and hasattr(backend, "service"): + # qiskit_ibm_runtime.IBMBackend stores its Provider-like object in + # the "service" attribute + self._provider = backend.service + # Experiment service like qiskit_ibm_experiment.IBMExperimentService, + # not to be confused with qiskit_ibm_runtime.QiskitRuntimeService self._service = service - if self._service is None and self.provider is not None: - self._service = self.get_service_from_provider(self.provider) - if self._service is None and self.provider is None and self.backend is not None: - self._service = self.get_service_from_backend(self.backend) self._auto_save = False self._created_in_db = False self._extra_data = kwargs @@ -321,7 +296,7 @@ def completion_times(self) -> Dict[str, datetime]: if hasattr(job, "time_per_step") and "COMPLETED" in job.time_per_step(): job_times[job_id] = job.time_per_step().get("COMPLETED") elif ( - execution := job.result().metadata.get("execution") + execution := getattr(job.result(), "metadata", {}).get("execution") ) and "execution_spans" in execution: job_times[job_id] = execution["execution_spans"].stop elif (client := getattr(job, "_api_client", None)) and hasattr( @@ -405,12 +380,16 @@ def updated_datetime(self) -> datetime: return self._db_data.updated_datetime @property - def running_time(self) -> datetime: + def running_time(self) -> datetime | None: """Return the running time of this experiment data. The running time is the time the latest successful job started running on the remote quantum machine. This can change as more jobs finish. + .. note:: + + In practice, this property is not currently set automatically by + Qiskit Experiments. """ return self._running_time @@ -604,27 +583,12 @@ def _set_backend(self, new_backend: Backend, recursive: bool = True) -> None: self._db_data.backend = self._backend_data.name if self._db_data.backend is None: self._db_data.backend = str(new_backend) - provider = self._backend_data.provider - if provider is not None: - self._set_hgp_from_provider(provider) - # qiskit-ibm-runtime style - elif hasattr(self._backend, "_instance") and self._backend._instance: + if hasattr(self._backend, "_instance") and self._backend._instance: self.hgp = self._backend._instance if recursive: for data in self.child_data(): data._set_backend(new_backend) - def _set_hgp_from_provider(self, provider): - try: - # qiskit-ibm-provider style - if hasattr(provider, "_hgps"): - for hgp_string, hgp in provider._hgps.items(): - if self.backend.name in hgp.backends: - self.hgp = hgp_string - break - except (AttributeError, IndexError, QiskitError): - pass - @property def hgp(self) -> str: """Returns Hub/Group/Project data as a formatted string""" @@ -674,6 +638,73 @@ def service(self, service: IBMExperimentService) -> None: """ self._set_service(service) + def _infer_service(self, warn: bool): + """Try to configure service if it has not been configured + + This method should be called before any method that needs to work with + the experiment service. + + Args: + warn: Warn if the service could not be set up from the backend or + provider attributes. + + Returns: + True if a service instance has been set up + """ + if self.service is None: + self.service = self.get_service_from_backend(self.backend) + if self.service is None: + self.service = self.get_service_from_provider(self.provider) + + if warn and self.service is None: + LOG.warning("Experiment service has not been configured. Can not save!") + + return self.service is not None + + def _set_service(self, service: IBMExperimentService) -> None: + """Set the service to be used for storing experiment data, + to this experiment itself and its descendants. + + Args: + service: Service to be used. + + Raises: + ExperimentDataError: If an experiment service is already being used and `replace==False`. + """ + if self._service is not None: + raise ExperimentDataError("An experiment service is already being used.") + self._service = service + with contextlib.suppress(Exception): + self.auto_save = self.service.options.get("auto_save", False) + for data in self.child_data(): + data._set_service(service) + + @staticmethod + def get_service_from_backend(backend) -> IBMExperimentService | None: + """Initializes the service from the backend data""" + # backend.provider is not checked since currently the only viable way + # to set up the experiment service is using the credentials from + # QiskitRuntimeService on a qiskit_ibm_runtime.IBMBackend. + provider = getattr(backend, "service", None) + return ExperimentData.get_service_from_provider(provider) + + @staticmethod + def get_service_from_provider(provider) -> IBMExperimentService | None: + """Initializes the service from the provider data""" + if not hasattr(provider, "active_account"): + return None + + account = provider.active_account() + url = account.get("url") + token = account.get("token") + try: + if url is not None and token is not None: + return IBMExperimentService(token=token, url=url) + except Exception: # pylint: disable=broad-except + LOG.warning("Failed to connect to experiment service", exc_info=True) + + return None + @property def provider(self) -> Optional[Provider]: """Return the backend provider. @@ -724,7 +755,7 @@ def source(self) -> Dict: def add_data( self, - data: Union[Result, List[Result], Dict, List[Dict]], + data: Union[Result, PrimitiveResult, List[Result | PrimitiveResult], Dict, List[Dict]], ) -> None: """Add experiment data. @@ -752,14 +783,14 @@ def add_data( for datum in data: if isinstance(datum, dict): self._result_data.append(datum) - elif isinstance(datum, Result): + elif isinstance(datum, (Result, PrimitiveResult)): self._add_result_data(datum) else: raise TypeError(f"Invalid data type {type(datum)}.") def add_jobs( self, - jobs: Union[Job, List[Job], BasePrimitiveJob, List[BasePrimitiveJob]], + jobs: Union[Job, List[Job]], timeout: Optional[float] = None, ) -> None: """Add experiment data. @@ -784,7 +815,7 @@ def add_jobs( "Not all analysis has finished running. Adding new jobs may " "create unexpected analysis results." ) - if isinstance(jobs, Job): + if not isinstance(jobs, Iterable): jobs = [jobs] # Add futures for extracting finished job data @@ -872,10 +903,6 @@ def _add_job_data( jid = job.job_id() try: job_result = job.result() - try: - self._running_time = job.time_per_step().get("running", None) - except AttributeError: - pass self._add_result_data(job_result, jid) LOG.debug("Job data added [Job ID: %s]", jid) # sets the endtime to be the time the last successful job was added @@ -999,7 +1026,11 @@ def _run_analysis_callback( LOG.warning(error_msg) return callback_id, False - def _add_result_data(self, result: Result, job_id: Optional[str] = None) -> None: + def _add_result_data( + self, + result: Result | PrimitiveResult, + job_id: Optional[str] = None, + ) -> None: """Add data from a Result object Args: @@ -1133,16 +1164,6 @@ def _retrieve_data(self): try: # qiskit-ibm-runtime syntax job = self.provider.job(jid) retrieved_jobs[jid] = job - except AttributeError: # TODO: remove this path for qiskit-ibm-provider - try: - job = self.provider.retrieve_job(jid) - retrieved_jobs[jid] = job - except Exception: # pylint: disable=broad-except - LOG.warning( - "Unable to retrieve data from job [Job ID: %s]: %s", - jid, - traceback.format_exc(), - ) except Exception: # pylint: disable=broad-except LOG.warning( "Unable to retrieve data from job [Job ID: %s]: %s", jid, traceback.format_exc() @@ -1299,10 +1320,10 @@ def add_figures( self._db_data.figure_names.append(fig_name) save = save_figure if save_figure is not None else self.auto_save - if save and self._service: + if save and self._infer_service(warn=True): if isinstance(figure, pyplot.Figure): figure = plot_to_svg_bytes(figure) - self._service.create_or_update_figure( + self.service.create_or_update_figure( experiment_id=self.experiment_id, figure=figure, figure_name=fig_name, @@ -1333,7 +1354,7 @@ def delete_figure( del self._figures[figure_key] self._deleted_figures.append(figure_key) - if self._service and self.auto_save: + if self.auto_save and self._infer_service(warn=True): with service_exception_to_warning(): self.service.delete_figure(experiment_id=self.experiment_id, figure_name=figure_key) self._deleted_figures.remove(figure_key) @@ -1382,7 +1403,7 @@ def figure( figure_key = self._find_figure_key(figure_key) figure_data = self._figures.get(figure_key, None) - if figure_data is None and self.service: + if figure_data is None and self._infer_service(warn=False): figure = self.service.figure(experiment_id=self.experiment_id, figure_name=figure_key) figure_data = FigureData(figure=figure, name=figure_key) self._figures[figure_key] = figure_data @@ -1489,10 +1510,10 @@ def add_analysis_results( created_time=created_time, **extra_values, ) - if self.auto_save: + if self.auto_save and self._infer_service(warn=True): service_result = _series_to_service_result( series=self._analysis_results.get_data(uid, columns="all").iloc[0], - service=self._service, + service=self.service, auto_save=False, ) service_result.save() @@ -1515,7 +1536,7 @@ def delete_analysis_result( """ uids = self._analysis_results.del_data(result_key) - if self._service and self.auto_save: + if self.auto_save and self._infer_service(warn=True): with service_exception_to_warning(): for uid in uids: self.service.delete_analysis_result(result_id=uid) @@ -1662,7 +1683,7 @@ def analysis_results( service_results.append( _series_to_service_result( series=series, - service=self._service, + service=self.service, auto_save=self._auto_save, ) ) @@ -1696,6 +1717,7 @@ def save_metadata(self) -> None: See :meth:`qiskit.providers.experiment.IBMExperimentService.create_experiment` for fields that are saved. """ + self._infer_service(warn=False) self._save_experiment_metadata() for data in self.child_data(): data.save_metadata() @@ -1714,7 +1736,7 @@ def _save_experiment_metadata(self, suppress_errors: bool = True) -> None: See :meth:`qiskit.providers.experiment.IBMExperimentService.create_experiment` for fields that are saved. """ - if not self._service: + if not self.service: LOG.warning( "Experiment cannot be saved because no experiment service is available. " "An experiment service is available, for example, " @@ -1792,7 +1814,8 @@ def save( additional tags or notes) use :meth:`save_metadata`. """ # TODO - track changes - if not self._service: + self._infer_service(warn=False) + if not self.service: LOG.warning( "Experiment cannot be saved because no experiment service is available. " "An experiment service is available, for example, " @@ -1828,7 +1851,7 @@ def save( # Calling API per entry takes huge amount of time. legacy_result = _series_to_service_result( series=series, - service=self._service, + service=self.service, auto_save=False, ) analysis_results_to_create.append(legacy_result._db_data) @@ -1849,7 +1872,7 @@ def save( for result in self._deleted_analysis_results.copy(): with service_exception_to_warning(): - self._service.delete_analysis_result(result_id=result) + self.service.delete_analysis_result(result_id=result) self._deleted_analysis_results.remove(result) if save_figures: @@ -1874,7 +1897,7 @@ def save( for name in self._deleted_figures.copy(): with service_exception_to_warning(): - self._service.delete_figure(experiment_id=self.experiment_id, figure_name=name) + self.service.delete_figure(experiment_id=self.experiment_id, figure_name=name) self._deleted_figures.remove(name) # save artifacts @@ -2518,26 +2541,6 @@ def _set_child_data(self, child_data: List[ExperimentData]): self.add_child_data(data) self._db_data.metadata["child_data_ids"] = self._child_data.keys() - def _set_service(self, service: IBMExperimentService, replace: bool = None) -> None: - """Set the service to be used for storing experiment data, - to this experiment itself and its descendants. - - Args: - service: Service to be used. - replace: Should an existing service be replaced? - If not, and a current service exists, exception is raised - - Raises: - ExperimentDataError: If an experiment service is already being used and `replace==False`. - """ - if self._service and not replace: - raise ExperimentDataError("An experiment service is already being used.") - self._service = service - with contextlib.suppress(Exception): - self.auto_save = self._service.options.get("auto_save", False) - for data in self.child_data(): - data._set_service(service) - def add_tags_recursive(self, tags2add: List[str]) -> None: """Add tags to this experiment itself and its descendants @@ -2684,37 +2687,6 @@ def __getstate__(self): return state - @staticmethod - def get_service_from_backend(backend): - """Initializes the service from the backend data""" - # qiskit-ibm-runtime style - try: - if hasattr(backend, "service"): - token = backend.service._account.token - return IBMExperimentService(token=token, url=backend.service._account.url) - return ExperimentData.get_service_from_provider(backend.provider) - except Exception: # pylint: disable=broad-except - return None - - @staticmethod - def get_service_from_provider(provider): - """Initializes the service from the provider data""" - try: - # qiskit-ibm-provider style - if hasattr(provider, "_account"): - warnings.warn( - "qiskit-ibm-provider has been deprecated in favor of qiskit-ibm-runtime. Support" - "for qiskit-ibm-provider backends will be removed in Qiskit Experiments 0.7.", - DeprecationWarning, - stacklevel=2, - ) - return IBMExperimentService( - token=provider._account.token, url=provider._account.url - ) - return None - except Exception: # pylint: disable=broad-except - return None - def __setstate__(self, state): self.__dict__.update(state) # Initialize non-pickled attributes diff --git a/qiskit_experiments/framework/provider_interfaces.py b/qiskit_experiments/framework/provider_interfaces.py new file mode 100644 index 0000000000..12c980b59b --- /dev/null +++ b/qiskit_experiments/framework/provider_interfaces.py @@ -0,0 +1,108 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +Definitions of interfaces for classes working with circuit execution + +Qiskit Experiments tries to maintain the flexibility to work with multiple +providers of quantum circuit execution, like Qiskit IBM Runtime, Qiskit +Dynamics, and Qiskit Aer. These different circuit execution providers do not +follow exactly the same interface. This module provides definitions of the +subset of the interfaces that Qiskit Experiments needs in order to analyze +experiment results. +""" + +from __future__ import annotations +from typing import Protocol, Union + +from qiskit.result import Result +from qiskit.primitives import PrimitiveResult +from qiskit.providers import Backend, JobStatus + + +class BaseJob(Protocol): + """Required interface definition of a job class as needed for experiment data""" + + def cancel(self): + """Cancel the job""" + raise NotImplementedError + + def job_id(self) -> str: + """Return the ID string for the job""" + raise NotImplementedError + + def result(self) -> Result | PrimitiveResult: + """Return the job result data""" + raise NotImplementedError + + def status(self) -> JobStatus | str: + """Return the status of the job""" + raise NotImplementedError + + +class ExtendedJob(BaseJob, Protocol): + """Job interface with methods to support all of experiment data's features""" + + def backend(self) -> Backend: + """Return the backend associated with a job""" + raise NotImplementedError + + def error_message(self) -> str | None: + """Returns the reason the job failed""" + raise NotImplementedError + + +Job = Union[BaseJob, ExtendedJob] +"""Union type of job interfaces supported by Qiskit Experiments""" + + +class BaseProvider(Protocol): + """Interface definition of a provider class as needed for experiment data""" + + def job(self, job_id: str) -> Job: + """Retrieve a job object using its job ID + + Args: + job_id: Job ID. + + Returns: + The retrieved job + """ + raise NotImplementedError + + +class IBMProvider(BaseProvider, Protocol): + """Provider interface needed for supporting features like IBM Quantum + + This interface is the subset of + :class:`~qiskit_ibm_runtime.QiskitRuntimeService` needed for all features + of Qiskit Experiments. Another provider could implement this interface to + support these features as well. + """ + + def active_account(self) -> dict[str, str] | None: + """Return the IBM Quantum account information currently in use + + This method returns the current account information in a dictionary + format. It is used to copy the credentials for use with + ``qiskit-ibm-experiment`` without requiring specifying the credentials + for the provider and ``qiskit-ibm-experiment`` separately + It should include ``"url"`` and ``"token"`` as keys for the + authentication to work. + + Returns: + A dictionary with information about the account currently in the session. + """ + raise NotImplementedError + + +Provider = Union[BaseProvider, IBMProvider] +"""Union type of provider interfaces supported by Qiskit Experiments""" diff --git a/releasenotes/notes/provider-service-types-47c262d5434047e5.yaml b/releasenotes/notes/provider-service-types-47c262d5434047e5.yaml new file mode 100644 index 0000000000..22d5ddb2bb --- /dev/null +++ b/releasenotes/notes/provider-service-types-47c262d5434047e5.yaml @@ -0,0 +1,23 @@ +--- +fixes: + - | + Fixed :class:`~.ExperimentData` not inferring the credentials for the IBM + experiment service from a :class:`~qiskit_ibm_runtime.QiskitRuntimeService` + instance as it used to do for ``qiskit-ibm-provider``. Previously, the IBM + experiment service was set up in the :class:`~.ExperimentData` constructor, + but now it is done on first attempt to use the service, allowing more time + for the service to be set explicitly or for other attributes to be set that + help with inferring the credentials. + - | + Fixed a bug where :meth:`.ExperimentData.add_data` would not work when + passed a single :class:`qiskit.primitives.PrimitiveResult` object. +developer: + - | + Added classes :class:`~qiskit_experiments.framework.BaseJob`, + :class:`~qiskit_experiments.framework.BaseJob`, + :class:`~qiskit_experiments.framework.ExtendedJob`, + :class:`~qiskit_experiments.framework.Job`, + :class:`~qiskit_experiments.framework.BaseProvider`, + :class:`~qiskit_experiments.framework.IBMProvider`, and + :class:`~qiskit_experiments.framework.Provider` to document the interfaces + needed by :class:`~.ExperimentData` to work with jobs and results.