From cfefec4abbac59228b0393af72a86cb744150c12 Mon Sep 17 00:00:00 2001 From: Will Shanks Date: Sun, 23 Nov 2025 17:53:10 -0500 Subject: [PATCH] Import optional dependencies less eagerly * Import `cvxpy` only when a cvxpy tomography fitter is used. * Import `qiskit_ibm_runtime` only when an experiment is run without passing a sampler argument (so that the backend can be wrapped in `SamplerV2`). * Add test that optional dependencies are not imported immediately with `qiskit_experiments` (note that `qiskit_ibm_runtime` eagerly imports `qiskit_aer` so it was necessary to delay its import to delay the `qiskit_aer` import). --- .../framework/base_experiment.py | 5 +- .../framework/composite/batch_experiment.py | 13 +++-- .../library/tomography/fitters/cvxpy_lstsq.py | 3 +- .../library/tomography/fitters/cvxpy_utils.py | 52 ++++++++++--------- .../notes/lazy-imports-730268f4cd8763dc.yaml | 9 ++++ ...{test_warnings.py => test_dependencies.py} | 52 +++++++++++++++---- 6 files changed, 91 insertions(+), 43 deletions(-) create mode 100644 releasenotes/notes/lazy-imports-730268f4cd8763dc.yaml rename test/framework/{test_warnings.py => test_dependencies.py} (60%) diff --git a/qiskit_experiments/framework/base_experiment.py b/qiskit_experiments/framework/base_experiment.py index 0db1c90d72..081ed2ce46 100644 --- a/qiskit_experiments/framework/base_experiment.py +++ b/qiskit_experiments/framework/base_experiment.py @@ -23,7 +23,6 @@ from qiskit.exceptions import QiskitError from qiskit.providers.options import Options from qiskit.primitives.base import BaseSamplerV2 -from qiskit_ibm_runtime import SamplerV2 as Sampler from qiskit_experiments.framework import BackendData, MeasLevel from qiskit_experiments.framework.store_init_args import StoreInitArgs from qiskit_experiments.framework.base_analysis import BaseAnalysis @@ -380,7 +379,9 @@ def _run_jobs( if not self._backend_run: if sampler is None: # instantiate a sampler from the backend - sampler = Sampler(self.backend) + from qiskit_ibm_runtime import SamplerV2 + + sampler = SamplerV2(self.backend) # have to hand set some of these options # see https://quantum.cloud.ibm.com/docs/api/qiskit-ibm-runtime diff --git a/qiskit_experiments/framework/composite/batch_experiment.py b/qiskit_experiments/framework/composite/batch_experiment.py index 351f7c3a42..41fd6d6e40 100644 --- a/qiskit_experiments/framework/composite/batch_experiment.py +++ b/qiskit_experiments/framework/composite/batch_experiment.py @@ -12,17 +12,20 @@ """ Batch Experiment class. """ +from __future__ import annotations -from typing import List, Optional, Dict +from typing import Dict, List, Optional, TYPE_CHECKING from collections import OrderedDict, defaultdict from qiskit import QuantumCircuit -from qiskit.providers import Job, Backend, Options -from qiskit_ibm_runtime import SamplerV2 as Sampler from .composite_experiment import CompositeExperiment, BaseExperiment from .composite_analysis import CompositeAnalysis +if TYPE_CHECKING: + from qiskit.primitives.base import BaseSamplerV2 + from qiskit.providers import Job, Backend, Options + class BatchExperiment(CompositeExperiment): """Combine multiple experiments into a batch experiment. @@ -138,7 +141,7 @@ def _run_jobs_recursive( self, circuits: List[QuantumCircuit], truncated_metadata: List[Dict], - sampler: Sampler = None, + sampler: BaseSamplerV2 = None, **run_options, ) -> List[Job]: # The truncated metadata is a truncation of the original composite metadata. @@ -177,7 +180,7 @@ def _run_jobs_recursive( return jobs def _run_jobs( - self, circuits: List[QuantumCircuit], sampler: Sampler = None, **run_options + self, circuits: List[QuantumCircuit], sampler: BaseSamplerV2 = None, **run_options ) -> List[Job]: truncated_metadata = [circ.metadata for circ in circuits] jobs = self._run_jobs_recursive(circuits, truncated_metadata, sampler, **run_options) diff --git a/qiskit_experiments/library/tomography/fitters/cvxpy_lstsq.py b/qiskit_experiments/library/tomography/fitters/cvxpy_lstsq.py index b8024581da..01e273edbb 100644 --- a/qiskit_experiments/library/tomography/fitters/cvxpy_lstsq.py +++ b/qiskit_experiments/library/tomography/fitters/cvxpy_lstsq.py @@ -22,7 +22,6 @@ PreparationBasis, ) from . import cvxpy_utils -from .cvxpy_utils import cvxpy from . import lstsq_utils from .fitter_data import _basis_dimensions @@ -136,6 +135,8 @@ def cvxpy_linear_lstsq( Returns: The fitted matrix rho that maximizes the least-squares likelihood function. """ + import cvxpy + t_start = time.time() if measurement_basis and measurement_qubits is None: diff --git a/qiskit_experiments/library/tomography/fitters/cvxpy_utils.py b/qiskit_experiments/library/tomography/fitters/cvxpy_utils.py index 030d4e05ee..81f3d80e55 100644 --- a/qiskit_experiments/library/tomography/fitters/cvxpy_utils.py +++ b/qiskit_experiments/library/tomography/fitters/cvxpy_utils.py @@ -12,32 +12,24 @@ """ Utility functions for CVXPy module """ +from __future__ import annotations -from typing import Callable, List, Tuple, Optional, Union import functools +from typing import Callable, List, TYPE_CHECKING, Tuple, Optional, Union + import numpy as np import scipy.sparse as sps from qiskit_experiments.exceptions import AnalysisError -# Check if CVXPY package is installed -try: - import cvxpy +# NOTE: this module gets eagerly imported with `qiskit_experiments/__init__.py` +# so it should not eagerly import cvxpy (a bit expensive to import when not +# needed). Any function outside of this module importing cvxpy or using a +# function from this module should be decorated with `requires_cvxpy`. +if TYPE_CHECKING: from cvxpy import Problem, Variable from cvxpy.constraints.constraint import Constraint - HAS_CVXPY = True - -except ImportError: - cvxpy = None - - HAS_CVXPY = False - - # Used for type hints - Problem = None - Variable = None - Constraint = None - def requires_cvxpy(func: Callable) -> Callable: """Function decorator for functions requiring CVXPy. @@ -49,16 +41,18 @@ def requires_cvxpy(func: Callable) -> Callable: The decorated function. Raises: - QiskitError: If CVXPy is not installed. + ImportError: If CVXPy is not installed. """ @functools.wraps(func) def decorated_func(*args, **kwargs): - if not HAS_CVXPY: + try: + import cvxpy # pylint: disable=unused-import + except ImportError as err: raise ImportError( - f"The CVXPY package is required to for {func}." + f"The CVXPY package is required for {func}." "You can install it with 'pip install cvxpy'." - ) + ) from err return func(*args, **kwargs) return decorated_func @@ -127,6 +121,8 @@ def complex_matrix_variable( A tuple ``(mat.real, mat.imag, constraints)`` of two real CVXPY matrix variables, and constraints. """ + import cvxpy + mat_r = cvxpy.Variable((dim, dim)) mat_i = cvxpy.Variable((dim, dim)) cons = [] @@ -163,6 +159,8 @@ def psd_constraint(mat_r: Variable, mat_i: Variable) -> List[Constraint]: Returns: A list of constraints on the real and imaginary parts. """ + import cvxpy + bmat = cvxpy.bmat([[mat_r, -mat_i], [mat_i, mat_r]]) return [bmat >> 0] @@ -187,9 +185,11 @@ def trace_constraint( Raises: TypeError: If input variables are not valid. """ + import cvxpy + if isinstance(mat_r, (list, tuple)): arg_r = cvxpy.sum(mat_r) - elif isinstance(mat_r, Variable): + elif isinstance(mat_r, cvxpy.Variable): arg_r = mat_r else: raise TypeError("Input must be a cvxpy variable or list of variables") @@ -201,7 +201,7 @@ def trace_constraint( # If not hermitian add imaginary trace constraint if isinstance(mat_i, (list, tuple)): arg_i = cvxpy.sum(mat_i) - elif isinstance(mat_i, Variable): + elif isinstance(mat_i, cvxpy.Variable): arg_i = mat_i else: raise TypeError("Input must be a cvxpy variable or list of variables") @@ -228,6 +228,8 @@ def partial_trace_constaint( Raises: TypeError: If input variables are not valid. """ + import cvxpy + sdim = mat_r.shape[0] output_dim = constraint.shape[0] input_dim = sdim // output_dim @@ -261,10 +263,12 @@ def trace_preserving_constaint( Raises: TypeError: If input variables are not valid. """ + import cvxpy + if isinstance(mat_r, (tuple, list)): sdim = mat_r[0].shape[0] arg_r = cvxpy.sum(mat_r) - elif isinstance(mat_r, Variable): + elif isinstance(mat_r, cvxpy.Variable): sdim = mat_r.shape[0] arg_r = mat_r else: @@ -282,7 +286,7 @@ def trace_preserving_constaint( # If not hermitian add imaginary partial trace constraint if isinstance(mat_i, (tuple, list)): arg_i = cvxpy.sum(mat_i) - elif isinstance(mat_i, Variable): + elif isinstance(mat_i, cvxpy.Variable): arg_i = mat_i else: raise TypeError("Input must be a cvxpy variable or list of variables") diff --git a/releasenotes/notes/lazy-imports-730268f4cd8763dc.yaml b/releasenotes/notes/lazy-imports-730268f4cd8763dc.yaml new file mode 100644 index 0000000000..f6d354dbeb --- /dev/null +++ b/releasenotes/notes/lazy-imports-730268f4cd8763dc.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Importing of optional dependencies has been made delayed until first use. + In particular, ``cvxpy`` is now not imported until a tomography fitter that + uses it is used. Additionally, ``qiskit_ibm_runtime`` is not imported an + experiment is run without being passed a sampler argument. Previously, both + packages were imported when ``qiskit_experiments`` was imported (``cvxpy`` + is optional, but it was always imported if it was available). diff --git a/test/framework/test_warnings.py b/test/framework/test_dependencies.py similarity index 60% rename from test/framework/test_warnings.py rename to test/framework/test_dependencies.py index 984f746c80..3a27cd9c55 100644 --- a/test/framework/test_warnings.py +++ b/test/framework/test_dependencies.py @@ -10,29 +10,59 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -# pylint: disable=unused-argument, unused-variable -"""Test warning helper.""" +"""Test behavior related package dependencies.""" import subprocess import sys import textwrap from test.base import QiskitExperimentsTestCase -from qiskit_experiments.framework import BaseExperiment +class TestOptionalDependencies(QiskitExperimentsTestCase): + """Test handling of optional dependencies -class TempExperiment(BaseExperiment): - """Fake experiment.""" + Note: these tests use subprocesses in order to test import handling. That + is an expensive operation compared to running code within a process that + has already imported everything (importing qiskit_experiments takes about 3 + seconds), so the amount of this kind of test should be kept to a minimum. + """ + + def test_no_optional_dependencies(self): + """Test that optional dependencies not imported by 'import qiskit_experiments'""" + script = """ + import sys + + import qiskit_experiments - def __init__(self, physical_qubits): - super().__init__(physical_qubits) - def circuits(self): - pass + top_level_modules = {m.partition(".")[0] for m in sys.modules} + + optional_deps = [ + "cvxpy", + "qiskit_aer", + "qiskit_ibm_runtime", + "sklearn", + ] + + unexpected = [d for d in optional_deps if d in top_level_modules] + if unexpected: + print(", ".join(unexpected)) + """ + script = textwrap.dedent(script) + proc = subprocess.run( + [sys.executable, "-c", script], check=False, text=True, capture_output=True + ) -class TestWarningsHelper(QiskitExperimentsTestCase): - """Test case for warnings decorator with tricky behavior.""" + self.assertTrue( + proc.stdout == "", + msg=f"Unexpected dependency imports: {proc.stdout}", + ) + self.assertEqual( + proc.returncode, + 0, + msg=f"Test script failed:\n{proc.stderr}", + ) def test_warn_sklearn(self): """Test that a suggestion to import scikit-learn is given when appropriate"""