Skip to content

Commit cfefec4

Browse files
committed
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).
1 parent 418eab7 commit cfefec4

File tree

6 files changed

+91
-43
lines changed

6 files changed

+91
-43
lines changed

qiskit_experiments/framework/base_experiment.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
from qiskit.exceptions import QiskitError
2424
from qiskit.providers.options import Options
2525
from qiskit.primitives.base import BaseSamplerV2
26-
from qiskit_ibm_runtime import SamplerV2 as Sampler
2726
from qiskit_experiments.framework import BackendData, MeasLevel
2827
from qiskit_experiments.framework.store_init_args import StoreInitArgs
2928
from qiskit_experiments.framework.base_analysis import BaseAnalysis
@@ -380,7 +379,9 @@ def _run_jobs(
380379
if not self._backend_run:
381380
if sampler is None:
382381
# instantiate a sampler from the backend
383-
sampler = Sampler(self.backend)
382+
from qiskit_ibm_runtime import SamplerV2
383+
384+
sampler = SamplerV2(self.backend)
384385

385386
# have to hand set some of these options
386387
# see https://quantum.cloud.ibm.com/docs/api/qiskit-ibm-runtime

qiskit_experiments/framework/composite/batch_experiment.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,20 @@
1212
"""
1313
Batch Experiment class.
1414
"""
15+
from __future__ import annotations
1516

16-
from typing import List, Optional, Dict
17+
from typing import Dict, List, Optional, TYPE_CHECKING
1718
from collections import OrderedDict, defaultdict
1819

1920
from qiskit import QuantumCircuit
20-
from qiskit.providers import Job, Backend, Options
21-
from qiskit_ibm_runtime import SamplerV2 as Sampler
2221

2322
from .composite_experiment import CompositeExperiment, BaseExperiment
2423
from .composite_analysis import CompositeAnalysis
2524

25+
if TYPE_CHECKING:
26+
from qiskit.primitives.base import BaseSamplerV2
27+
from qiskit.providers import Job, Backend, Options
28+
2629

2730
class BatchExperiment(CompositeExperiment):
2831
"""Combine multiple experiments into a batch experiment.
@@ -138,7 +141,7 @@ def _run_jobs_recursive(
138141
self,
139142
circuits: List[QuantumCircuit],
140143
truncated_metadata: List[Dict],
141-
sampler: Sampler = None,
144+
sampler: BaseSamplerV2 = None,
142145
**run_options,
143146
) -> List[Job]:
144147
# The truncated metadata is a truncation of the original composite metadata.
@@ -177,7 +180,7 @@ def _run_jobs_recursive(
177180
return jobs
178181

179182
def _run_jobs(
180-
self, circuits: List[QuantumCircuit], sampler: Sampler = None, **run_options
183+
self, circuits: List[QuantumCircuit], sampler: BaseSamplerV2 = None, **run_options
181184
) -> List[Job]:
182185
truncated_metadata = [circ.metadata for circ in circuits]
183186
jobs = self._run_jobs_recursive(circuits, truncated_metadata, sampler, **run_options)

qiskit_experiments/library/tomography/fitters/cvxpy_lstsq.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
PreparationBasis,
2323
)
2424
from . import cvxpy_utils
25-
from .cvxpy_utils import cvxpy
2625
from . import lstsq_utils
2726
from .fitter_data import _basis_dimensions
2827

@@ -136,6 +135,8 @@ def cvxpy_linear_lstsq(
136135
Returns:
137136
The fitted matrix rho that maximizes the least-squares likelihood function.
138137
"""
138+
import cvxpy
139+
139140
t_start = time.time()
140141

141142
if measurement_basis and measurement_qubits is None:

qiskit_experiments/library/tomography/fitters/cvxpy_utils.py

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,32 +12,24 @@
1212
"""
1313
Utility functions for CVXPy module
1414
"""
15+
from __future__ import annotations
1516

16-
from typing import Callable, List, Tuple, Optional, Union
1717
import functools
18+
from typing import Callable, List, TYPE_CHECKING, Tuple, Optional, Union
19+
1820
import numpy as np
1921
import scipy.sparse as sps
2022

2123
from qiskit_experiments.exceptions import AnalysisError
2224

23-
# Check if CVXPY package is installed
24-
try:
25-
import cvxpy
25+
# NOTE: this module gets eagerly imported with `qiskit_experiments/__init__.py`
26+
# so it should not eagerly import cvxpy (a bit expensive to import when not
27+
# needed). Any function outside of this module importing cvxpy or using a
28+
# function from this module should be decorated with `requires_cvxpy`.
29+
if TYPE_CHECKING:
2630
from cvxpy import Problem, Variable
2731
from cvxpy.constraints.constraint import Constraint
2832

29-
HAS_CVXPY = True
30-
31-
except ImportError:
32-
cvxpy = None
33-
34-
HAS_CVXPY = False
35-
36-
# Used for type hints
37-
Problem = None
38-
Variable = None
39-
Constraint = None
40-
4133

4234
def requires_cvxpy(func: Callable) -> Callable:
4335
"""Function decorator for functions requiring CVXPy.
@@ -49,16 +41,18 @@ def requires_cvxpy(func: Callable) -> Callable:
4941
The decorated function.
5042
5143
Raises:
52-
QiskitError: If CVXPy is not installed.
44+
ImportError: If CVXPy is not installed.
5345
"""
5446

5547
@functools.wraps(func)
5648
def decorated_func(*args, **kwargs):
57-
if not HAS_CVXPY:
49+
try:
50+
import cvxpy # pylint: disable=unused-import
51+
except ImportError as err:
5852
raise ImportError(
59-
f"The CVXPY package is required to for {func}."
53+
f"The CVXPY package is required for {func}."
6054
"You can install it with 'pip install cvxpy'."
61-
)
55+
) from err
6256
return func(*args, **kwargs)
6357

6458
return decorated_func
@@ -127,6 +121,8 @@ def complex_matrix_variable(
127121
A tuple ``(mat.real, mat.imag, constraints)`` of two real CVXPY
128122
matrix variables, and constraints.
129123
"""
124+
import cvxpy
125+
130126
mat_r = cvxpy.Variable((dim, dim))
131127
mat_i = cvxpy.Variable((dim, dim))
132128
cons = []
@@ -163,6 +159,8 @@ def psd_constraint(mat_r: Variable, mat_i: Variable) -> List[Constraint]:
163159
Returns:
164160
A list of constraints on the real and imaginary parts.
165161
"""
162+
import cvxpy
163+
166164
bmat = cvxpy.bmat([[mat_r, -mat_i], [mat_i, mat_r]])
167165
return [bmat >> 0]
168166

@@ -187,9 +185,11 @@ def trace_constraint(
187185
Raises:
188186
TypeError: If input variables are not valid.
189187
"""
188+
import cvxpy
189+
190190
if isinstance(mat_r, (list, tuple)):
191191
arg_r = cvxpy.sum(mat_r)
192-
elif isinstance(mat_r, Variable):
192+
elif isinstance(mat_r, cvxpy.Variable):
193193
arg_r = mat_r
194194
else:
195195
raise TypeError("Input must be a cvxpy variable or list of variables")
@@ -201,7 +201,7 @@ def trace_constraint(
201201
# If not hermitian add imaginary trace constraint
202202
if isinstance(mat_i, (list, tuple)):
203203
arg_i = cvxpy.sum(mat_i)
204-
elif isinstance(mat_i, Variable):
204+
elif isinstance(mat_i, cvxpy.Variable):
205205
arg_i = mat_i
206206
else:
207207
raise TypeError("Input must be a cvxpy variable or list of variables")
@@ -228,6 +228,8 @@ def partial_trace_constaint(
228228
Raises:
229229
TypeError: If input variables are not valid.
230230
"""
231+
import cvxpy
232+
231233
sdim = mat_r.shape[0]
232234
output_dim = constraint.shape[0]
233235
input_dim = sdim // output_dim
@@ -261,10 +263,12 @@ def trace_preserving_constaint(
261263
Raises:
262264
TypeError: If input variables are not valid.
263265
"""
266+
import cvxpy
267+
264268
if isinstance(mat_r, (tuple, list)):
265269
sdim = mat_r[0].shape[0]
266270
arg_r = cvxpy.sum(mat_r)
267-
elif isinstance(mat_r, Variable):
271+
elif isinstance(mat_r, cvxpy.Variable):
268272
sdim = mat_r.shape[0]
269273
arg_r = mat_r
270274
else:
@@ -282,7 +286,7 @@ def trace_preserving_constaint(
282286
# If not hermitian add imaginary partial trace constraint
283287
if isinstance(mat_i, (tuple, list)):
284288
arg_i = cvxpy.sum(mat_i)
285-
elif isinstance(mat_i, Variable):
289+
elif isinstance(mat_i, cvxpy.Variable):
286290
arg_i = mat_i
287291
else:
288292
raise TypeError("Input must be a cvxpy variable or list of variables")
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
features:
3+
- |
4+
Importing of optional dependencies has been made delayed until first use.
5+
In particular, ``cvxpy`` is now not imported until a tomography fitter that
6+
uses it is used. Additionally, ``qiskit_ibm_runtime`` is not imported an
7+
experiment is run without being passed a sampler argument. Previously, both
8+
packages were imported when ``qiskit_experiments`` was imported (``cvxpy``
9+
is optional, but it was always imported if it was available).

test/framework/test_warnings.py renamed to test/framework/test_dependencies.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,59 @@
1010
# copyright notice, and modified files need to carry a notice indicating
1111
# that they have been altered from the originals.
1212

13-
# pylint: disable=unused-argument, unused-variable
14-
"""Test warning helper."""
13+
"""Test behavior related package dependencies."""
1514

1615
import subprocess
1716
import sys
1817
import textwrap
1918
from test.base import QiskitExperimentsTestCase
2019

21-
from qiskit_experiments.framework import BaseExperiment
2220

21+
class TestOptionalDependencies(QiskitExperimentsTestCase):
22+
"""Test handling of optional dependencies
2323
24-
class TempExperiment(BaseExperiment):
25-
"""Fake experiment."""
24+
Note: these tests use subprocesses in order to test import handling. That
25+
is an expensive operation compared to running code within a process that
26+
has already imported everything (importing qiskit_experiments takes about 3
27+
seconds), so the amount of this kind of test should be kept to a minimum.
28+
"""
29+
30+
def test_no_optional_dependencies(self):
31+
"""Test that optional dependencies not imported by 'import qiskit_experiments'"""
32+
script = """
33+
import sys
34+
35+
import qiskit_experiments
2636
27-
def __init__(self, physical_qubits):
28-
super().__init__(physical_qubits)
2937
30-
def circuits(self):
31-
pass
38+
top_level_modules = {m.partition(".")[0] for m in sys.modules}
39+
40+
optional_deps = [
41+
"cvxpy",
42+
"qiskit_aer",
43+
"qiskit_ibm_runtime",
44+
"sklearn",
45+
]
46+
47+
unexpected = [d for d in optional_deps if d in top_level_modules]
48+
if unexpected:
49+
print(", ".join(unexpected))
50+
"""
51+
script = textwrap.dedent(script)
3252

53+
proc = subprocess.run(
54+
[sys.executable, "-c", script], check=False, text=True, capture_output=True
55+
)
3356

34-
class TestWarningsHelper(QiskitExperimentsTestCase):
35-
"""Test case for warnings decorator with tricky behavior."""
57+
self.assertTrue(
58+
proc.stdout == "",
59+
msg=f"Unexpected dependency imports: {proc.stdout}",
60+
)
61+
self.assertEqual(
62+
proc.returncode,
63+
0,
64+
msg=f"Test script failed:\n{proc.stderr}",
65+
)
3666

3767
def test_warn_sklearn(self):
3868
"""Test that a suggestion to import scikit-learn is given when appropriate"""

0 commit comments

Comments
 (0)