diff --git a/qiskit_experiments/framework/experiment_data.py b/qiskit_experiments/framework/experiment_data.py index a4cd953967..4d27c3a452 100644 --- a/qiskit_experiments/framework/experiment_data.py +++ b/qiskit_experiments/framework/experiment_data.py @@ -16,7 +16,7 @@ from __future__ import annotations import logging import re -from typing import Dict, Optional, List, Union, Any, Callable, Tuple, TYPE_CHECKING +from typing import Dict, Optional, List, Union, Any, Callable, Protocol, Tuple, TYPE_CHECKING from datetime import datetime, timezone from concurrent import futures from functools import wraps @@ -81,6 +81,48 @@ LOG = logging.getLogger(__name__) +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] + + def do_auto_save(func: Callable): """Decorate the input function to auto save data.""" @@ -261,12 +303,15 @@ def __init__( self._set_backend(backend, recursive=False) self.provider = provider if provider is None and backend is not None: - self.provider = backend.provider - 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) + # 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 self._auto_save = False self._created_in_db = False self._extra_data = kwargs @@ -604,27 +649,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 +704,66 @@ 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 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`. + """ + 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""" + 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 + + 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) + @property def provider(self) -> Optional[Provider]: """Return the backend provider. @@ -1133,16 +1223,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 +1379,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 +1413,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 +1462,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 +1569,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 +1595,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 +1742,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 +1776,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 +1795,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 +1873,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 +1910,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 +1931,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 +1956,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 +2600,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 +2746,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